diff --git a/Cargo.lock b/Cargo.lock
index b1c1399..d0d3f72 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4262,7 +4262,7 @@ dependencies = [
[[package]]
name = "pstr-android"
-version = "0.2.0"
+version = "0.3.0"
dependencies = [
"android_logger",
"jni 0.21.1",
@@ -4281,7 +4281,7 @@ dependencies = [
[[package]]
name = "pstr-app"
-version = "0.2.0"
+version = "0.3.0"
dependencies = [
"anyhow",
"eframe",
@@ -4303,7 +4303,7 @@ dependencies = [
[[package]]
name = "pstr-cli"
-version = "0.2.0"
+version = "0.3.0"
dependencies = [
"anyhow",
"clap",
@@ -4321,7 +4321,7 @@ dependencies = [
[[package]]
name = "pstr-core"
-version = "0.2.0"
+version = "0.3.0"
dependencies = [
"directories",
"futures",
@@ -4339,7 +4339,7 @@ dependencies = [
[[package]]
name = "pstr-meta"
-version = "0.2.0"
+version = "0.3.0"
dependencies = [
"pstr-core",
"reqwest",
@@ -4352,12 +4352,13 @@ dependencies = [
[[package]]
name = "pstr-player"
-version = "0.2.0"
+version = "0.3.0"
dependencies = [
"async-trait",
"libloading",
"libmpv2",
"libmpv2-sys",
+ "pstr-core",
"pstr-stream",
"thiserror 2.0.19",
"tokio",
@@ -4366,7 +4367,7 @@ dependencies = [
[[package]]
name = "pstr-stream"
-version = "0.2.0"
+version = "0.3.0"
dependencies = [
"async-trait",
"futures",
diff --git a/Cargo.toml b/Cargo.toml
index 7e49d9e..f0b6d6a 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -11,7 +11,7 @@ members = [
]
[workspace.package]
-version = "0.2.0"
+version = "0.3.0"
edition = "2024"
license = "MIT"
repository = "https://github.com/narrrl/proton-stream"
diff --git a/android/.editorconfig b/android/.editorconfig
new file mode 100644
index 0000000..4740e98
--- /dev/null
+++ b/android/.editorconfig
@@ -0,0 +1,29 @@
+# ktlint configuration.
+#
+# The whole standard ruleset is off, and rules are enabled one at a time. That
+# is deliberate: turning it all on reports ~540 findings on the existing tree,
+# almost all of them line-wrapping preference, and a mechanical reformat of
+# every file would bury the changes that matter in review. The rules kept here
+# are the ones whose violation is a defect or a real maintenance cost rather
+# than a matter of taste — starting with the unused imports that were the
+# evidence no gate ran at all.
+#
+# Adding a rule is cheap. Do it with the reformat it implies in its own commit.
+
+[*.{kt,kts}]
+ktlint_standard = disabled
+ktlint_standard_no-unused-imports = enabled
+ktlint_standard_no-wildcard-imports = enabled
+ktlint_standard_no-semi = enabled
+ktlint_standard_no-trailing-spaces = enabled
+ktlint_standard_no-consecutive-blank-lines = enabled
+ktlint_standard_no-empty-file = enabled
+ktlint_standard_final-newline = enabled
+ktlint_standard_filename = enabled
+ktlint_standard_no-line-break-before-assignment = enabled
+ktlint_standard_no-multi-spaces = enabled
+
+# Vendored from rustls-platform-verifier. Restyling it would turn the next sync
+# with upstream into a merge conflict for no benefit.
+[**/org/rustls/**.kt]
+ktlint = disabled
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 890af25..753ee81 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -5,6 +5,7 @@ plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
+ alias(libs.plugins.ktlint)
}
val repositoryRoot = rootProject.projectDir.parentFile
@@ -53,6 +54,7 @@ android {
targetSdk = 36
versionCode = releaseVersionCode
versionName = releaseVersionName
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
ndk {
abiFilters += listOf("arm64-v8a", "x86_64")
@@ -103,7 +105,51 @@ android {
isMinifyEnabled = true
isShrinkResources = true
if (releaseStore != null) signingConfig = signingConfigs.getByName("release")
- proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro",
+ // Applied to the shipping APK on purpose; the file explains the
+ // trade-off and what each keep is repairing.
+ "proguard-instrumentation.pro",
+ )
+ testProguardFiles("proguard-test-rules.pro")
+ }
+ }
+
+ // The minified path is what ships, and R8 breaks reflective UniFFI/JNA
+ // surfaces in ways no debug run can see. CI sets this to `release` and runs
+ // connectedAndroidTest against it; locally the default keeps builds fast.
+ testBuildType = providers.environmentVariable("ANDROID_TEST_BUILD_TYPE").orNull ?: "debug"
+
+ lint {
+ warningsAsErrors = true
+ abortOnError = true
+ checkDependencies = true
+ // The baseline records what was already here when the gate went in, so
+ // anything *new* fails. It is not a permanent exemption: every entry in
+ // it maps to an open bug in docs/BUGS.md (the SharedPreferences
+ // `commit()` calls are B20, the PiP one is B40), and the file should get
+ // shorter as those land. Regenerate with `gradlew updateLintBaseline`
+ // after a fix, and never to make an unrelated failure go away.
+ baseline = file("lint-baseline.xml")
+ // Generated UniFFI bindings are not ours to fix.
+ disable += setOf("UnusedIds")
+ // Dependency freshness is not a defect, and as errors these break the
+ // gate whenever someone else cuts a release. Upgrades are a deliberate
+ // act with their own verification; `gradlew lintDebug -Dlint...` or a
+ // dependency bot is the right place to surface them, not CI.
+ informational += setOf(
+ "GradleDependency",
+ "NewerVersionAvailable",
+ "AndroidGradlePluginVersion",
+ )
+ sarifReport = true
+ }
+
+ testOptions {
+ unitTests {
+ isIncludeAndroidResources = true
+ isReturnDefaultValues = true
}
}
@@ -209,7 +255,6 @@ dependencies {
implementation(libs.androidx.work.runtime)
implementation(libs.jna) { artifact { type = "aar" } }
-
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.tooling.preview)
@@ -219,5 +264,36 @@ dependencies {
implementation(libs.androidx.compose.material3.adaptive)
implementation(libs.androidx.compose.material3.navigation.suite)
debugImplementation(libs.androidx.compose.ui.tooling)
+
testImplementation(libs.junit)
+ testImplementation(libs.robolectric)
+ testImplementation(libs.androidx.test.core)
+ testImplementation(libs.androidx.work.testing)
+
+ androidTestImplementation(libs.androidx.test.junit)
+ androidTestImplementation(libs.androidx.test.core)
+ androidTestImplementation(libs.androidx.test.runner)
+ androidTestImplementation(libs.androidx.work.testing)
+ androidTestImplementation(platform(libs.androidx.compose.bom))
+ androidTestImplementation(libs.androidx.compose.ui.test.junit4)
+ debugImplementation(libs.androidx.compose.ui.test.manifest)
}
+
+ktlint {
+ version.set("1.5.0")
+ android.set(true)
+ ignoreFailures.set(false)
+ filter {
+ // UniFFI writes these; they are regenerated on every build.
+ exclude { it.file.path.contains("${File.separator}generated${File.separator}") }
+ // Vendored from rustls-platform-verifier. Restyling it would make the
+ // next sync with upstream a merge conflict for no benefit.
+ exclude { it.file.path.contains("${File.separator}org${File.separator}rustls${File.separator}") }
+ }
+}
+
+// The generated UniFFI directory is a main source dir, so ktlint reads it even
+// though the filter above drops every file in it. Without this, Gradle refuses
+// the build for an undeclared dependency between the two tasks.
+tasks.matching { it.name.startsWith("runKtlintCheckOver") || it.name.startsWith("runKtlintFormatOver") }
+ .configureEach { dependsOn(generateUniFfiBindings) }
diff --git a/android/app/lint-baseline.xml b/android/app/lint-baseline.xml
new file mode 100644
index 0000000..56f688f
--- /dev/null
+++ b/android/app/lint-baseline.xml
@@ -0,0 +1,268 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/proguard-instrumentation.pro b/android/app/proguard-instrumentation.pro
new file mode 100644
index 0000000..b3ebaa5
--- /dev/null
+++ b/android/app/proguard-instrumentation.pro
@@ -0,0 +1,98 @@
+# Keeps that exist so the instrumentation suite can run against the *release*
+# build. They are applied to the shipping APK, which is a real trade-off worth
+# stating plainly: the tested artifact is not byte-identical to an artifact
+# built without them.
+#
+# It is the better side of the trade. The alternative is to run instrumentation
+# against `debug` only, which leaves R8 — the step that has already broken this
+# app twice, in ways no debug run can reproduce — entirely uncovered. What is
+# kept here is a few kilobytes of framework plumbing containing no application
+# logic and revealing nothing about the app.
+#
+# Why these keeps are needed at all: the instrumentation APK is minified in a
+# separate R8 run that consumes the app's mapping file, and links against the
+# app's classes at runtime. That arrangement fails in two ways.
+#
+# 1. The mapping file carries renames but not R8's parameter-permutation
+# optimisation, so a permuted method resolves to the wrong descriptor from
+# the test APK.
+# 2. A class that only the test APK reaches is unreachable from the app's call
+# graph, so R8 removes it from the app APK — and the test APK does not
+# bundle it, because it is supposed to come from the app.
+
+# The Kotlin runtime is where both failures land, because androidx.test is
+# itself Kotlin and shares the app's copy of the stdlib. Keeping it wholesale
+# rather than class by class is deliberate: each individual keep only moves the
+# crash to the next facade the runner happens to touch, which is a list nobody
+# can enumerate ahead of time. Three were hit before this rule replaced them:
+#
+# NoSuchMethodError: No static method f(Ljava/lang/Object;Ljava/lang/String;)V
+# in class Lo6/k; (o6.k = kotlin.jvm.internal.Intrinsics,
+# whose parameters R8 had permuted —
+# every Kotlin class calls it on entry,
+# so nothing ran at all)
+# NoClassDefFoundError: Lkotlin/LazyKt; (androidx.test.platform.io.TestDirCalculator)
+#
+# Cost is roughly a megabyte of dex against a ~100 MB APK that is almost
+# entirely native libraries.
+-keep class kotlin.** { *; }
+
+# `AndroidJUnitRunner.onCreate` traces its own startup. Nothing in the app
+# reaches androidx.tracing, so R8 dropped it and the runner could not start:
+#
+# java.lang.NoClassDefFoundError: Failed resolution of: Landroidx/tracing/Trace;
+# at androidx.test.runner.AndroidJUnitRunner.onCreate(AndroidJUnitRunner.java:307)
+-keep class androidx.tracing.** { *; }
+
+# A third shape of the same problem, and the one worth understanding: the app
+# only ever touches WorkManager through its implementation, so R8 vertically
+# merged the abstract `androidx.work.WorkManager` into `WorkManagerImpl` and the
+# abstract type stopped existing. It is absent from mapping.txt entirely rather
+# than renamed. The download tests declare fields of the public types, so JUnit's
+# field scan could not even construct the test class:
+#
+# java.lang.NoClassDefFoundError: Failed resolution of: Landroidx/work/WorkManager;
+# at org.junit.runners.model.TestClass.getSortedDeclaredFields(TestClass.java:77)
+#
+# Kept package-wide because the same merge can happen to any of the public API
+# types the tests name (WorkInfo, Data, Constraints), and finding out one crash
+# at a time costs a four-minute device round trip each.
+-keep class androidx.work.** { *; }
+
+# Everything above is framework plumbing. The last group is app code, and it is
+# the one keep in this file that costs real fidelity: R8 no longer optimises
+# these classes in the shipping build, so the suite no longer proves R8 handles
+# them correctly. It is a narrow loss — the surfaces R8 has actually
+# broken here are the FFI ones, and those are kept for functional reasons in
+# proguard-rules.pro and stay under test.
+#
+# The trigger was `DownloadStateStore.clear()`. It is not dead code — AppViewModel
+# calls it — but it has exactly one call site, so R8 inlined it and dropped the
+# method. Only a caller that resolves it by name, which is to say a test, notices:
+#
+# java.lang.NoSuchMethodError: No virtual method clear()V
+# in class Lio/narl/protonstream/download/c;
+#
+# Package-wide rather than class-by-class: Kotlin's top-level functions live in
+# a synthesised `...Kt` facade that no source file names, so keeping the classes
+# a test imports still leaves it missing `DownloadCoordinatorKt`.
+-keep class io.narl.protonstream.download.** { *; }
+
+# Same inlining, one layer out: the `wifiOnly` setter behind the download
+# constraint.
+# java.lang.NoSuchMethodError: No virtual method setWifiOnly(Z)V in class Li5/a;
+-keep class io.narl.protonstream.settings.SettingsStore { *; }
+
+# The live cases drive the player directly, so the same inlining applies to the
+# playback classes they name.
+-keep class io.narl.protonstream.playback.** { *; }
+
+# `runBlocking` — the live cases drive suspending bridge calls from a JUnit
+# thread. The app itself never blocks a thread on a coroutine, so R8 drops the
+# facade that holds it:
+#
+# java.lang.NoClassDefFoundError: Failed resolution of: Lkotlinx/coroutines/BuildersKt;
+#
+# Kept narrowly rather than as `kotlinx.coroutines.**`: that library is large and
+# on the app's hot paths, and pinning all of it would cost real optimisation.
+-keep class kotlinx.coroutines.BuildersKt** { *; }
diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro
index f26ea4f..ba490b0 100644
--- a/android/app/proguard-rules.pro
+++ b/android/app/proguard-rules.pro
@@ -7,3 +7,19 @@
-keepclasseswithmembernames class * {
native ;
}
+
+# UniFFI records are Kotlin data classes whose generic signatures and
+# annotations survive into the runtime; stripping them changes how the bindings
+# lift and lower structured types.
+-keepattributes Signature,*Annotation*,InnerClasses,EnclosingMethod
+
+# JNA dispatches into callbacks reflectively by method name.
+-keepclassmembers class * implements com.sun.jna.Callback {
+ ;
+}
+
+# The bridge calls back into these from Rust. Nothing in the Kotlin call graph
+# reaches their overrides, so R8 is free to rename or drop them.
+-keep class io.narl.protonstream.native.KeystoreSecretStore { *; }
+-keep class * implements uniffi.pstr_android.AndroidSecretStore { *; }
+-keep class * implements uniffi.pstr_android.DownloadObserver { *; }
diff --git a/android/app/proguard-test-rules.pro b/android/app/proguard-test-rules.pro
new file mode 100644
index 0000000..09898a9
--- /dev/null
+++ b/android/app/proguard-test-rules.pro
@@ -0,0 +1,11 @@
+# Applied only to the instrumentation APK when androidTest runs against a
+# minified build (ANDROID_TEST_BUILD_TYPE=release). The tests exist to prove the
+# *app's* R8 configuration is right, so nothing here may relax the app's rules —
+# it only keeps the test harness itself intact.
+-dontobfuscate
+-keep class io.narl.protonstream.** { *; }
+-keep class androidx.test.** { *; }
+-dontwarn androidx.test.**
+-keepclasseswithmembers class * {
+ @org.junit.Test ;
+}
diff --git a/android/app/src/androidTest/java/io/narl/protonstream/download/DownloadCoordinatorInstrumentedTest.kt b/android/app/src/androidTest/java/io/narl/protonstream/download/DownloadCoordinatorInstrumentedTest.kt
new file mode 100644
index 0000000..cf0e5cd
--- /dev/null
+++ b/android/app/src/androidTest/java/io/narl/protonstream/download/DownloadCoordinatorInstrumentedTest.kt
@@ -0,0 +1,129 @@
+package io.narl.protonstream.download
+
+import android.content.Context
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.work.Configuration
+import androidx.work.NetworkType
+import androidx.work.WorkInfo
+import androidx.work.WorkManager
+import androidx.work.testing.SynchronousExecutor
+import androidx.work.testing.WorkManagerTestInitHelper
+import io.narl.protonstream.settings.SettingsStore
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * What actually reaches WorkManager. The unit tests cover the pure helpers; this
+ * covers the enqueue itself — the input data (which is persisted unencrypted, so
+ * it must carry no secret), the constraints, and the unique-work identity that
+ * pause and resume depend on.
+ */
+@RunWith(AndroidJUnit4::class)
+class DownloadCoordinatorInstrumentedTest {
+ private lateinit var context: Context
+ private lateinit var workManager: WorkManager
+
+ @Before
+ fun setUp() {
+ context = ApplicationProvider.getApplicationContext()
+ WorkManagerTestInitHelper.initializeTestWorkManager(
+ context,
+ Configuration.Builder().setExecutor(SynchronousExecutor()).build(),
+ )
+ workManager = WorkManager.getInstance(context)
+ workManager.cancelAllWork().result.get()
+ DownloadStateStore(context).clear()
+ }
+
+ private fun download(linkId: String = "link-1", shareId: String = "share-1") = RetainedDownload(
+ shareId = shareId,
+ volumeId = "volume-1",
+ linkId = linkId,
+ label = "S01E01.mkv",
+ )
+
+ private fun infoFor(download: RetainedDownload): WorkInfo? =
+ workManager.getWorkInfosForUniqueWork(
+ DownloadCoordinator.workName(download.shareId, download.linkId),
+ ).get().firstOrNull()
+
+ @Test
+ fun resumingEnqueuesUniqueWorkAndRetainsAQueuedRecord() {
+ val download = download()
+ DownloadCoordinator.resume(context, download)
+
+ assertNotNull(infoFor(download))
+ assertEquals(
+ RetainedDownload.STATUS_QUEUED,
+ DownloadStateStore(context).get("share-1", "link-1")?.status,
+ )
+ }
+
+ /**
+ * Tags are persisted in `androidx.work.workdb` unencrypted and are what the
+ * UI queries and what cancel-by-share matches on. Pinning the exact set is
+ * what makes an accidental addition — a fragment, a URL — visible in review.
+ */
+ @Test
+ fun theWorkCarriesExactlyTheExpectedTags() {
+ val download = download()
+ DownloadCoordinator.resume(context, download)
+
+ val tags = infoFor(download)?.tags.orEmpty()
+ assertTrue(DownloadCoordinator.TAG in tags)
+ assertTrue(DownloadCoordinator.shareTag("share-1") in tags)
+ assertTrue("${DownloadCoordinator.EPISODE_TAG}S01E01.mkv" in tags)
+ // The remaining tag is WorkManager's own worker class name.
+ assertEquals(4, tags.size)
+ }
+
+ @Test
+ fun theWifiOnlySettingReachesTheConstraint() {
+ // Documents B29: the constraint is read at enqueue time, so work already
+ // queued keeps whatever was set when it was queued. When B29 is fixed,
+ // this test grows a case for flipping the toggle after enqueue.
+ SettingsStore(context).wifiOnly = true
+ assertEquals(NetworkType.UNMETERED, requiredNetworkType(SettingsStore(context).wifiOnly))
+ SettingsStore(context).wifiOnly = false
+ assertEquals(NetworkType.CONNECTED, requiredNetworkType(SettingsStore(context).wifiOnly))
+ }
+
+ @Test
+ fun pauseCancelsTheWorkAndRecordsThePausedStatus() {
+ val download = download()
+ DownloadCoordinator.resume(context, download)
+ DownloadCoordinator.pause(context, download)
+
+ assertTrue(infoFor(download)?.state?.isFinished ?: false)
+ assertEquals(
+ RetainedDownload.STATUS_PAUSED,
+ DownloadStateStore(context).get("share-1", "link-1")?.status,
+ )
+ }
+
+ @Test
+ fun cancellingOneShareLeavesAnotherSharesWorkAlone() {
+ val first = download(linkId = "a", shareId = "share-1")
+ val second = download(linkId = "b", shareId = "share-2")
+ DownloadCoordinator.resume(context, first)
+ DownloadCoordinator.resume(context, second)
+
+ workManager.cancelAllWorkByTag(DownloadCoordinator.shareTag("share-1")).result.get()
+
+ assertTrue(infoFor(first)?.state?.isFinished ?: false)
+ assertEquals(WorkInfo.State.ENQUEUED, infoFor(second)?.state)
+ }
+
+ @Test
+ fun twoEpisodesOfOneShareEnqueueSeparately() {
+ DownloadCoordinator.resume(context, download(linkId = "a"))
+ DownloadCoordinator.resume(context, download(linkId = "b"))
+
+ assertEquals(2, workManager.getWorkInfosByTag(DownloadCoordinator.TAG).get().size)
+ }
+}
diff --git a/android/app/src/androidTest/java/io/narl/protonstream/live/LiveShareTest.kt b/android/app/src/androidTest/java/io/narl/protonstream/live/LiveShareTest.kt
new file mode 100644
index 0000000..9aeff87
--- /dev/null
+++ b/android/app/src/androidTest/java/io/narl/protonstream/live/LiveShareTest.kt
@@ -0,0 +1,274 @@
+package io.narl.protonstream.live
+
+import android.graphics.SurfaceTexture
+import android.view.Surface
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import io.narl.protonstream.playback.NativeMpvHost
+import java.io.File
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicLong
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Assume.assumeTrue
+import org.junit.BeforeClass
+import org.junit.Test
+import org.junit.runner.RunWith
+import uniffi.pstr_android.AndroidEngine
+import uniffi.pstr_android.AndroidPaths
+import uniffi.pstr_android.AndroidSecretStore
+import uniffi.pstr_android.DownloadObserver
+import uniffi.pstr_android.EpisodeRecord
+
+/**
+ * The cases `docs/ANDROID.md` requires that cannot be answered without a real
+ * share: that a link resolves into a playable catalog, that libmpv actually
+ * decodes the decrypted stream, that a cancelled download leaves a resumable
+ * `.part`, and that a resume position survives a restart.
+ *
+ * The link is an instrumentation argument rather than a constant or a file, so
+ * the secret never enters the repository:
+ *
+ * ```
+ * -Pandroid.testInstrumentationRunnerArguments.pstr.shareUrl=...
+ * ```
+ *
+ * Without it every test here is skipped by assumption, and
+ * `scripts/android-acceptance.sh` reports the matrix as incomplete.
+ *
+ * Read-only against the share by construction: nothing here uploads, renames or
+ * deletes anything remote. It writes only to this test's own temporary
+ * directories.
+ */
+@RunWith(AndroidJUnit4::class)
+class LiveShareTest {
+ @Test
+ fun theShareResolvesIntoAPlayableCatalog() {
+ val episodes = episodes()
+ assertTrue("the crawl found no playable file in the share", episodes.isNotEmpty())
+ // A file with no size cannot be streamed: the block layer needs the
+ // length to place block boundaries at all.
+ val sized = episodes.filter { (it.size ?: 0uL) > 0uL }
+ assertTrue("no episode in the share reported a size", sized.isNotEmpty())
+ }
+
+ @Test
+ fun openingAnEpisodeYieldsASeekableRevision() {
+ val episode = playable()
+ val stream = runBlocking {
+ engine.openStream(episode.shareId, episode.volumeId, episode.linkId)
+ }
+ try {
+ assertTrue("the opened revision has no length", stream.size() > 0uL)
+ // Without a revision id the block cache cannot tell one revision of
+ // a file from another, and a stale block would be served as fresh.
+ assertTrue("the opened revision has no id", stream.revisionId().isNotBlank())
+ } finally {
+ runBlocking { engine.releaseStream(episode.shareId, episode.volumeId, episode.linkId) }
+ }
+ }
+
+ /**
+ * The whole pipeline in one assertion: Proton block storage, decryption,
+ * the Rust stream C ABI, the JNI adapter and libmpv's demuxer. If the
+ * position advances, real decrypted bytes reached the decoder.
+ */
+ @Test
+ fun libmpvDecodesTheDecryptedStream() {
+ val episode = playable()
+ val texture = SurfaceTexture(0).apply { setDefaultBufferSize(1280, 720) }
+ val surface = Surface(texture)
+ val host = NativeMpvHost()
+ val key = "${episode.shareId}:${episode.linkId}"
+ try {
+ host.attachSurface(surface)
+ val stream = runBlocking {
+ engine.openStream(episode.shareId, episode.volumeId, episode.linkId)
+ }
+ runBlocking { host.play(key, stream.nativeHandle(), stream.size()) }
+ host.setMuted(true)
+ host.setPaused(false)
+
+ val playing = waitFor(PLAYBACK_TIMEOUT_MS) { host.state.value.position > 0.0 }
+ assertTrue(
+ "libmpv never advanced past zero: ${host.state.value}",
+ playing,
+ )
+ // One host serves every episode in turn, so a reading that does not
+ // name this one would resume the wrong file.
+ assertEquals(key, host.state.value.media)
+ assertTrue("libmpv reported no duration", host.state.value.duration > 0.0)
+ } finally {
+ host.stop()
+ host.detachSurface()
+ host.close()
+ surface.release()
+ texture.release()
+ runBlocking { engine.releaseStream(episode.shareId, episode.volumeId, episode.linkId) }
+ }
+ }
+
+ /**
+ * Cancellation is polled between whole Proton blocks precisely so the bytes
+ * already written stay usable. This checks the retained `.part` is counted
+ * as partial rather than as a finished file, and that resuming continues
+ * from it instead of starting over.
+ */
+ @Test
+ fun aCancelledDownloadLeavesAResumablePartFile() {
+ val episode = playable()
+ val firstRun = CancelAfter(bytes = RESUME_THRESHOLD_BYTES)
+ runCatching {
+ runBlocking {
+ engine.downloadEpisode(episode.shareId, episode.volumeId, episode.linkId, firstRun)
+ }
+ }
+ assumeTrue(
+ "the episode finished before cancellation could be observed; it is too small for this case",
+ !firstRun.completed,
+ )
+ assertTrue("the download reported no progress at all", firstRun.observed > 0L)
+
+ val retained = engine.storageUsage()
+ assertTrue("a cancelled download retained no bytes", retained.partialBytes > 0uL)
+ assertEquals("a cancelled download counted as a finished file", 0uL, retained.offlineCount)
+
+ // Resuming must pick up where the .part ended rather than at zero.
+ val secondRun = CancelAfter(bytes = retained.partialBytes.toLong() + RESUME_THRESHOLD_BYTES)
+ runCatching {
+ runBlocking {
+ engine.downloadEpisode(episode.shareId, episode.volumeId, episode.linkId, secondRun)
+ }
+ }
+ assertTrue(
+ "resuming restarted from zero: first reading was ${secondRun.firstReading}, " +
+ "but ${retained.partialBytes} bytes were already retained",
+ secondRun.firstReading >= retained.partialBytes.toLong(),
+ )
+
+ runBlocking { engine.removeOfflineEpisode(episode.shareId, episode.linkId) }
+ assertEquals(
+ "removing the episode left partial bytes behind",
+ 0uL,
+ engine.storageUsage().partialBytes,
+ )
+ }
+
+ /** What the viewer sees as "continue watching" after the app is killed. */
+ @Test
+ fun aResumePositionSurvivesReopeningTheCatalog() {
+ val episode = playable()
+ engine.saveWatchState(episode.shareId, episode.linkId, RESUME_POSITION, null, false)
+
+ val reopened = AndroidEngine(paths, secrets)
+ val state = reopened.watchState(episode.shareId, episode.linkId)
+ assertNotNull("the resume position did not survive a reopen", state)
+ assertEquals(RESUME_POSITION, state!!.positionSecs, 0.001)
+ assertEquals(false, state.watched)
+
+ // The catalog view is what the UI reads, so it has to agree.
+ val listed = reopened.library(null)
+ .flatMap { it.seasons }
+ .flatMap { it.episodes }
+ .first { it.linkId == episode.linkId }
+ assertEquals(RESUME_POSITION, listed.resumeAt ?: -1.0, 0.001)
+ }
+
+ /** Cancels once the download has written enough to prove a resume. */
+ private class CancelAfter(private val bytes: Long) : DownloadObserver {
+ private val cancelled = AtomicBoolean(false)
+ private val progress = AtomicLong(0)
+ private val first = AtomicLong(-1)
+
+ val observed: Long get() = progress.get()
+ val firstReading: Long get() = first.get()
+ val completed: Boolean get() = !cancelled.get()
+
+ override fun onProgress(downloaded: ULong, total: ULong) {
+ first.compareAndSet(-1, downloaded.toLong())
+ progress.set(downloaded.toLong())
+ if (downloaded.toLong() >= bytes) cancelled.set(true)
+ }
+
+ override fun isCancelled(): Boolean = cancelled.get()
+ }
+
+ private fun episodes(): List =
+ engine.library(null).flatMap { it.seasons }.flatMap { it.episodes }
+
+ private fun playable(): EpisodeRecord =
+ episodes().firstOrNull { (it.size ?: 0uL) > 0uL }
+ ?: throw AssertionError("the share has no sized file to play")
+
+ private fun waitFor(timeoutMs: Long, condition: () -> Boolean): Boolean {
+ val deadline = System.currentTimeMillis() + timeoutMs
+ while (System.currentTimeMillis() < deadline) {
+ if (condition()) return true
+ Thread.sleep(200)
+ }
+ return false
+ }
+
+ /** Stands in for Keystore, so a bridge failure cannot be blamed on crypto. */
+ private class MemorySecretStore : AndroidSecretStore {
+ private val entries = ConcurrentHashMap()
+
+ override fun set(key: String, value: String) {
+ entries[key] = value
+ }
+
+ override fun get(key: String): String? = entries[key]
+
+ override fun delete(key: String) {
+ entries.remove(key)
+ }
+ }
+
+ companion object {
+ private const val SHARE_URL_ARGUMENT = "pstr.shareUrl"
+ private const val SHARE_PASSWORD_ARGUMENT = "pstr.sharePassword"
+
+ /** Long enough for a cold connection, a crawl and a first block read. */
+ private const val PLAYBACK_TIMEOUT_MS = 60_000L
+
+ /** Enough written bytes to prove a resume, small enough to stay quick. */
+ private const val RESUME_THRESHOLD_BYTES = 4L * 1024 * 1024
+
+ private const val RESUME_POSITION = 137.5
+
+ private lateinit var paths: AndroidPaths
+ private lateinit var secrets: AndroidSecretStore
+ private lateinit var engine: AndroidEngine
+
+ @BeforeClass
+ @JvmStatic
+ fun openTheShare() {
+ System.loadLibrary("pstr_android")
+ val arguments = InstrumentationRegistry.getArguments()
+ val url = arguments.getString(SHARE_URL_ARGUMENT)
+ assumeTrue(
+ "set -Pandroid.testInstrumentationRunnerArguments.$SHARE_URL_ARGUMENT to run the live cases",
+ !url.isNullOrBlank(),
+ )
+ val password = arguments.getString(SHARE_PASSWORD_ARGUMENT)?.takeIf { it.isNotBlank() }
+
+ val root = File.createTempFile("pstr-live", "").let {
+ it.delete()
+ it.mkdirs()
+ it
+ }
+ paths = AndroidPaths(
+ File(root, "config").absolutePath,
+ File(root, "data").absolutePath,
+ File(root, "cache").absolutePath,
+ )
+ secrets = MemorySecretStore()
+ engine = AndroidEngine(paths, secrets)
+ engine.addShare("acceptance", url!!, password)
+ runBlocking { engine.crawl(null) }
+ }
+ }
+}
diff --git a/android/app/src/androidTest/java/io/narl/protonstream/native/KeystoreSecretStoreTest.kt b/android/app/src/androidTest/java/io/narl/protonstream/native/KeystoreSecretStoreTest.kt
new file mode 100644
index 0000000..23354ab
--- /dev/null
+++ b/android/app/src/androidTest/java/io/narl/protonstream/native/KeystoreSecretStoreTest.kt
@@ -0,0 +1,155 @@
+package io.narl.protonstream.native
+
+import android.content.Context
+import android.util.Base64
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import java.security.KeyStore
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertThrows
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import uniffi.pstr_android.BridgeException
+
+/**
+ * Runs on a device because Android Keystore has no host implementation — which
+ * is why nothing pinned this before. The share fragment and any custom link
+ * password are the only user secrets the app holds, and losing the key makes
+ * every share unopenable, so the failure modes matter as much as the happy path.
+ */
+@RunWith(AndroidJUnit4::class)
+class KeystoreSecretStoreTest {
+ private lateinit var context: Context
+ private lateinit var store: KeystoreSecretStore
+
+ @Before
+ fun setUp() {
+ context = ApplicationProvider.getApplicationContext()
+ clearPreferences()
+ store = KeystoreSecretStore(context)
+ }
+
+ @After
+ fun tearDown() {
+ clearPreferences()
+ }
+
+ @Test
+ fun aSecretSurvivesARoundTrip() {
+ store.set("share-1", "correct horse battery staple")
+ assertEquals("correct horse battery staple", store.get("share-1"))
+ }
+
+ @Test
+ fun anAbsentSecretIsNullRatherThanEmpty() {
+ assertNull(store.get("never-written"))
+ }
+
+ @Test
+ fun deleteRemovesTheSecret() {
+ store.set("share-1", "value")
+ store.delete("share-1")
+ assertNull(store.get("share-1"))
+ }
+
+ @Test
+ fun aUnicodeSecretIsNotMangled() {
+ val value = "パスワード・🎬・ü"
+ store.set("share-1", value)
+ assertEquals(value, store.get("share-1"))
+ }
+
+ /**
+ * GCM is catastrophic under IV reuse: two secrets encrypted with the same
+ * key and IV leak the XOR of their plaintexts. `Cipher.init(ENCRYPT_MODE)`
+ * is what generates a fresh one, so this pins that the code never pins it.
+ */
+ @Test
+ fun everyWriteUsesAFreshInitialisationVector() {
+ store.set("share-1", "same plaintext")
+ val first = rawPayload("share-1")
+ store.set("share-1", "same plaintext")
+ val second = rawPayload("share-1")
+
+ val firstIv = first.copyOfRange(0, IV_SIZE)
+ val secondIv = second.copyOfRange(0, IV_SIZE)
+ assertNotEquals(Base64.encodeToString(firstIv, Base64.NO_WRAP), Base64.encodeToString(secondIv, Base64.NO_WRAP))
+ }
+
+ @Test
+ fun theStoredFormIsNotThePlaintext() {
+ store.set("share-1", "correct horse battery staple")
+ val stored = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
+ .getString("share-1", null)
+ assertTrue(stored != null && !stored.contains("correct horse"))
+ }
+
+ /**
+ * A tampered payload must not decrypt, and must fail as a [BridgeException].
+ * An `AEADBadTagException` crossing the FFI has no mapping on the Rust side,
+ * and an unmapped callback exception panics the calling thread (B18).
+ */
+ @Test
+ fun aTamperedPayloadFailsToDecrypt() {
+ store.set("share-1", "value")
+ val payload = rawPayload("share-1")
+ payload[payload.size - 1] = (payload[payload.size - 1].toInt() xor 0x01).toByte()
+ writeRawPayload("share-1", payload)
+
+ assertThrows(BridgeException::class.java) { store.get("share-1") }
+ }
+
+ @Test
+ fun aTruncatedPayloadIsRejectedBeforeDecryption() {
+ store.set("share-1", "value")
+ writeRawPayload("share-1", rawPayload("share-1").copyOfRange(0, IV_SIZE))
+
+ assertThrows(BridgeException::class.java) { store.get("share-1") }
+ }
+
+ /**
+ * The device-side half of B18: this is what a lockscreen change or a device
+ * restore does to the key. It must fail the read rather than the process,
+ * and the store must still accept a re-entered secret afterwards — which is
+ * what the shares screen's "Re-enter link" depends on.
+ */
+ @Test
+ fun aLostKeyFailsTheReadRatherThanTheProcess() {
+ store.set("share-1", "value")
+ KeyStore.getInstance("AndroidKeyStore").apply { load(null) }.deleteEntry(KEY_ALIAS)
+
+ assertThrows(BridgeException::class.java) { store.get("share-1") }
+
+ // The store must still be usable for a re-entered secret.
+ store.set("share-1", "re-entered")
+ assertEquals("re-entered", store.get("share-1"))
+ }
+
+ private fun rawPayload(key: String): ByteArray {
+ val encoded = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
+ .getString(key, null) ?: error("no payload for $key")
+ return Base64.decode(encoded, Base64.NO_WRAP)
+ }
+
+ private fun writeRawPayload(key: String, payload: ByteArray) {
+ context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
+ .edit()
+ .putString(key, Base64.encodeToString(payload, Base64.NO_WRAP))
+ .commit()
+ }
+
+ private fun clearPreferences() {
+ context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE).edit().clear().commit()
+ }
+
+ private companion object {
+ const val PREFERENCES = "proton_stream_secrets"
+ const val KEY_ALIAS = "proton-stream-share-secrets-v1"
+ const val IV_SIZE = 12
+ }
+}
diff --git a/android/app/src/androidTest/java/io/narl/protonstream/native/UniFfiBoundaryTest.kt b/android/app/src/androidTest/java/io/narl/protonstream/native/UniFfiBoundaryTest.kt
new file mode 100644
index 0000000..354a83b
--- /dev/null
+++ b/android/app/src/androidTest/java/io/narl/protonstream/native/UniFfiBoundaryTest.kt
@@ -0,0 +1,117 @@
+package io.narl.protonstream.native
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import java.io.File
+import java.util.concurrent.ConcurrentHashMap
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.BeforeClass
+import org.junit.Test
+import org.junit.runner.RunWith
+import uniffi.pstr_android.AndroidEngine
+import uniffi.pstr_android.AndroidPaths
+import uniffi.pstr_android.AndroidSecretStore
+import uniffi.pstr_android.chapterPlan
+import uniffi.pstr_android.skipOffer
+
+/**
+ * The first thing that crosses the UniFFI boundary on a real device. It is
+ * deliberately offline: it proves the ABI, the generated bindings, the callback
+ * interface and the SQLite open all work, without needing a share.
+ *
+ * If this fails and the Rust workspace tests pass, the fault is in packaging —
+ * a missing ABI in `jniLibs`, a stale binding, or an R8 rule (run it with
+ * `ANDROID_TEST_BUILD_TYPE=release` to check the shipped configuration).
+ */
+@RunWith(AndroidJUnit4::class)
+class UniFfiBoundaryTest {
+ /** Stands in for Keystore so a bridge failure cannot be blamed on crypto. */
+ private class MemorySecretStore : AndroidSecretStore {
+ val entries = ConcurrentHashMap()
+ var reads = 0
+ private set
+
+ override fun set(key: String, value: String) {
+ entries[key] = value
+ }
+
+ override fun get(key: String): String? {
+ reads += 1
+ return entries[key]
+ }
+
+ override fun delete(key: String) {
+ entries.remove(key)
+ }
+ }
+
+ private fun engine(secrets: AndroidSecretStore = MemorySecretStore()): AndroidEngine {
+ val root = File.createTempFile("pstr-bridge", "").let {
+ it.delete()
+ it.mkdirs()
+ it
+ }
+ return AndroidEngine(
+ AndroidPaths(
+ File(root, "config").absolutePath,
+ File(root, "data").absolutePath,
+ File(root, "cache").absolutePath,
+ ),
+ secrets,
+ )
+ }
+
+ @Test
+ fun theEngineOpensAgainstEmptyDirectories() {
+ val engine = engine()
+ assertTrue(engine.shares().isEmpty())
+ assertTrue(engine.library(null).isEmpty())
+ }
+
+ @Test
+ fun storageUsageReportsZeroOnAFreshInstall() {
+ val usage = engine().storageUsage()
+ assertNotNull(usage)
+ assertEquals(0UL, usage.offlineBytes)
+ assertEquals(0UL, usage.partialBytes)
+ assertEquals(0UL, usage.offlineCount)
+ }
+
+ @Test
+ fun metadataSettingsAreReadableAndDefaultToDisabled() {
+ // Enrichment is opt-in; a default of enabled would send titles to a
+ // third party before the viewer has agreed to it.
+ assertEquals(false, engine().metadataSettings().enabled)
+ }
+
+ @Test
+ fun aSecondEngineReopensTheSameCatalog() {
+ val secrets = MemorySecretStore()
+ engine(secrets).shares()
+ // Reopening is the process-restart path; a schema or lock fault shows up
+ // here rather than on a user's device.
+ assertTrue(engine(secrets).shares().isEmpty())
+ }
+
+ /**
+ * Chapter classification is shared Rust that desktop already pins
+ * (`docs/BUGS.md` B12). This runs the same code through the bindings, so a
+ * lowering fault in the record types cannot pass unnoticed.
+ */
+ @Test
+ fun chapterPlanningCrossesTheBoundaryIntact() {
+ val plan = chapterPlan(emptyList(), 1560.0)
+ assertNotNull(plan)
+ assertNull(skipOffer(plan, 12.0))
+ }
+
+ companion object {
+ @BeforeClass
+ @JvmStatic
+ fun loadLibrary() {
+ System.loadLibrary("pstr_android")
+ }
+ }
+}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index b997ab7..c49a54c 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -29,7 +29,7 @@
+#include
#include
#include
#include
@@ -86,16 +87,32 @@ int64_t stream_size(void *opaque) noexcept {
});
}
+/// mpv wants its request slot back — a seek, or a teardown.
+///
+/// The flag alone only stops the *next* read; the one already parked in a 4 MiB
+/// block fetch has to be interrupted in Rust, or `mpv_terminate_destroy` waits
+/// out the whole fetch while joining the demuxer thread.
void stream_cancel(void *opaque) noexcept {
c_boundary([=] {
if (auto *stream = static_cast(opaque)) {
stream->cancelled.store(true, std::memory_order_relaxed);
+ pstr_android_stream_cancel(stream->handle);
}
});
}
+/// Releases the Rust stream behind the file mpv has finished with.
+///
+/// Ownership sits here rather than in Kotlin because this is the one moment
+/// that is *defined* to be after mpv's last read: `loadfile` is asynchronous, so
+/// a Kotlin-side release issued when the next load returns can land while the
+/// outgoing demuxer is still reading — and, if the release names the handle the
+/// registry has already swapped, takes the incoming episode's stream with it.
void stream_close(void *opaque) noexcept {
- c_boundary([=] { delete static_cast(opaque); });
+ c_boundary([=] {
+ std::unique_ptr cookie(static_cast(opaque));
+ if (cookie) pstr_android_stream_release(cookie->handle);
+ });
}
int stream_open(void *, char *uri, mpv_stream_cb_info *info) noexcept {
@@ -130,6 +147,29 @@ void *resolve_gl(void *, const char *name) noexcept {
});
}
+/// Why the open file ended, mirroring `pstr_player::EndReason` so that the two
+/// clients agree on what "finished" means. Only [Eof] is an episode the viewer
+/// watched to the end; the rest are a stop, a shutdown or a failure, and none of
+/// them may mark anything watched or advance to the next episode.
+enum class EndReason : int {
+ None = 0,
+ Eof = 1,
+ Stopped = 2,
+ Quit = 3,
+ Failed = 4,
+ Other = 5,
+};
+
+EndReason end_reason_of(int reason) {
+ switch (reason) {
+ case MPV_END_FILE_REASON_EOF: return EndReason::Eof;
+ case MPV_END_FILE_REASON_STOP: return EndReason::Stopped;
+ case MPV_END_FILE_REASON_QUIT: return EndReason::Quit;
+ case MPV_END_FILE_REASON_ERROR: return EndReason::Failed;
+ default: return EndReason::Other;
+ }
+}
+
struct PlaybackState {
double position = 0.0;
double duration = 0.0;
@@ -137,6 +177,30 @@ struct PlaybackState {
bool paused = true;
bool muted = false;
bool ended = false;
+ EndReason end_reason = EndReason::None;
+ /// mpv has run out of buffered data and stopped to refill. Distinct from
+ /// [paused], which is the viewer's doing: a frozen picture that nothing
+ /// explains is indistinguishable from a hung app.
+ bool buffering = false;
+ /// How full the demuxer cache is while [buffering], 0–100.
+ double cache_percent = 0.0;
+ /// Between a seek being issued and playback actually resuming. The seek bar
+ /// has moved but the picture has not.
+ bool seeking = false;
+ /// True between `loadfile` being issued and mpv acknowledging it with
+ /// `START_FILE`. Everything mpv reports in that window still describes the
+ /// *outgoing* file — including the `END_FILE(STOP)` that stopping it emits —
+ /// and must not be attributed to the file being loaded.
+ bool loading = false;
+ /// The picture's display size, once mpv has decoded enough to know it.
+ /// Zero until then, and the aspect Picture-in-Picture is given.
+ double video_width = 0.0;
+ double video_height = 0.0;
+ /// Which loaded file this state describes. Bumped by every load, so a
+ /// reader can tell "episode two, second zero" from a leftover reading of
+ /// episode one — which is the difference between resuming an episode where
+ /// the viewer left it and resuming it where the *previous* one ended.
+ int64_t generation = 0;
};
class Player {
@@ -165,6 +229,13 @@ class Player {
observe("pause", 3, MPV_FORMAT_FLAG);
observe("volume", 4, MPV_FORMAT_DOUBLE);
observe("mute", 5, MPV_FORMAT_FLAG);
+ observe("video-params/dw", 6, MPV_FORMAT_INT64);
+ observe("video-params/dh", 7, MPV_FORMAT_INT64);
+ observe("paused-for-cache", 8, MPV_FORMAT_FLAG);
+ observe("cache-buffering-state", 9, MPV_FORMAT_INT64);
+ // Errors mpv reports about the file itself, rather than about the
+ // request that opened it. Without these a failed demux is silent.
+ mpv_request_log_messages(mpv_, "error");
events_ = std::thread([this] { event_loop(); });
renderer_ = std::thread([this] { render_loop(); });
}
@@ -213,23 +284,60 @@ class Player {
bool load(uint64_t handle, double start, const std::string &audio,
const std::string &subtitle, bool subtitles) {
if (!mpv_) return false;
- {
- std::unique_lock lock(render_mutex_);
- if (!render_cv_.wait_for(lock, std::chrono::seconds(5), [this] { return render_ != nullptr || !running_.load(); })) return false;
- }
+ // No wait for a render context. It is created with the render thread and
+ // needs no window, and a load that cannot get one plays audio anyway —
+ // waiting for a surface here is what made background playback fail after
+ // a five-second stall.
if (!audio.empty()) set_string("alang", audio);
if (!subtitle.empty()) set_string("slang", subtitle);
set_string("sid", subtitles ? "auto" : "no");
pending_start_.store(std::max(0.0, start));
+ // Retire the outgoing file's position, duration and end flag *before*
+ // the new one loads. mpv only republishes them once it has demuxed
+ // enough of the stream, and until then every reader would otherwise see
+ // the last episode's clock under the new episode's name.
+ {
+ std::lock_guard lock(state_mutex_);
+ const double volume = state_.volume;
+ const bool muted = state_.muted;
+ // `pause` is a property of the core, not of the file: mpv carries it
+ // across `loadfile` unchanged and therefore never re-publishes it.
+ // Resetting it to the struct's default would leave every reader
+ // believing a playing file is paused for as long as it is open, and
+ // every "play" they then send is a no-op against a core that never
+ // paused — which is a play/pause button that does nothing.
+ const bool paused = state_.paused;
+ const int64_t generation = state_.generation + 1;
+ state_ = PlaybackState{};
+ state_.volume = volume;
+ state_.muted = muted;
+ state_.paused = paused;
+ state_.generation = generation;
+ state_.loading = true;
+ error_.clear();
+ }
const std::string url = "pstr://" + std::to_string(handle);
const char *command[] = {"loadfile", url.c_str(), nullptr};
- if (mpv_command(mpv_, command) < 0) return false;
+ if (mpv_command(mpv_, command) < 0) {
+ // Nothing was issued, so no START_FILE will arrive to clear this.
+ std::lock_guard lock(state_mutex_);
+ state_.loading = false;
+ return false;
+ }
return true;
}
- void pause(bool value) { set_flag("pause", value); }
+ /// Written through rather than left to the observer: the transport is drawn
+ /// from this state, and a tap that only shows its effect on mpv's next
+ /// property event reads as a button that missed.
+ void pause(bool value) {
+ set_flag("pause", value);
+ std::lock_guard lock(state_mutex_);
+ state_.paused = value;
+ }
void seek(double seconds) { set_double("time-pos", std::max(0.0, seconds)); }
void volume(double value) { set_double("volume", std::clamp(value, 0.0, 100.0)); }
+ void speed(double value) { set_double("speed", std::clamp(value, 0.25, 4.0)); }
void mute(bool value) { set_flag("mute", value); }
void select_track(bool audio, int64_t id) {
const char *property = audio ? "aid" : "sid";
@@ -246,6 +354,12 @@ class Player {
return state_;
}
+ /// The pending error, if any, consumed by reading it.
+ std::string take_error() {
+ std::lock_guard lock(state_mutex_);
+ return std::exchange(error_, std::string());
+ }
+
std::string tracks_json() const {
if (!mpv_) return "[]";
int64_t count = 0;
@@ -274,22 +388,115 @@ class Player {
return json + ']';
}
+ /// The file's chapters, read one sub-property at a time like the tracks.
+ /// What they *mean* is decided in Rust (`pstr_core::chapters`), so the
+ /// desktop and this client offer the same skips.
+ std::string chapters_json() const {
+ if (!mpv_) return "[]";
+ int64_t count = 0;
+ if (mpv_get_property(mpv_, "chapter-list/count", MPV_FORMAT_INT64, &count) < 0) return "[]";
+ std::string json = "[";
+ for (int64_t i = 0; i < count; ++i) {
+ const std::string base = "chapter-list/" + std::to_string(i) + "/";
+ char *title_raw = nullptr;
+ double start = 0.0;
+ mpv_get_property(mpv_, (base + "title").c_str(), MPV_FORMAT_STRING, &title_raw);
+ MpvString title(title_raw);
+ mpv_get_property(mpv_, (base + "time").c_str(), MPV_FORMAT_DOUBLE, &start);
+ if (i) json += ',';
+ json += "{\"index\":" + std::to_string(i) + ",\"title\":\"" + escape(title.get()) +
+ "\",\"start\":" + std::to_string(start) + '}';
+ }
+ return json + ']';
+ }
+
private:
struct MpvFree {
void operator()(char *value) const noexcept { if (value) mpv_free(value); }
};
using MpvString = std::unique_ptr;
+ /// JSON-escape `text`, and re-encode it as Modified UTF-8.
+ ///
+ /// The result of this ends up in `NewStringUTF`, which does not take UTF-8:
+ /// it takes Modified UTF-8, where a character outside the basic plane is a
+ /// surrogate *pair* of three-byte sequences rather than one four-byte
+ /// sequence. Handing it real UTF-8 renders an emoji in a track title as
+ /// mojibake, and aborts the process under CheckJNI.
+ ///
+ /// Anything that is not well-formed UTF-8 is dropped rather than passed
+ /// through, for the same reason: a truncated sequence is what CheckJNI
+ /// rejects, and a track title is not worth a crash.
static std::string escape(const char *text) {
std::string out;
if (!text) return out;
- for (const unsigned char c : std::string_view(text)) {
- if (c == '"' || c == '\\') out += '\\';
- if (c >= 0x20) out += static_cast(c);
+ const std::string_view input(text);
+ for (size_t i = 0; i < input.size();) {
+ const auto lead = static_cast(input[i]);
+ size_t length = 0;
+ uint32_t code = 0;
+ if (lead < 0x80) {
+ length = 1;
+ code = lead;
+ } else if ((lead & 0xE0) == 0xC0) {
+ length = 2;
+ code = lead & 0x1Fu;
+ } else if ((lead & 0xF0) == 0xE0) {
+ length = 3;
+ code = lead & 0x0Fu;
+ } else if ((lead & 0xF8) == 0xF0) {
+ length = 4;
+ code = lead & 0x07u;
+ } else {
+ ++i; // A stray continuation or invalid lead byte.
+ continue;
+ }
+ if (i + length > input.size()) break;
+ bool valid = true;
+ for (size_t k = 1; k < length; ++k) {
+ const auto next = static_cast(input[i + k]);
+ if ((next & 0xC0) != 0x80) {
+ valid = false;
+ break;
+ }
+ code = (code << 6) | (next & 0x3Fu);
+ }
+ if (!valid) {
+ ++i;
+ continue;
+ }
+ i += length;
+
+ if (code < 0x20) continue; // Control characters, including NUL.
+ if (code == '"' || code == '\\') {
+ out += '\\';
+ out += static_cast(code);
+ continue;
+ }
+ if (code < 0x80) {
+ out += static_cast(code);
+ } else if (code < 0x800) {
+ out += static_cast(0xC0 | (code >> 6));
+ out += static_cast(0x80 | (code & 0x3F));
+ } else if (code < 0x10000) {
+ append_three(out, code);
+ } else {
+ // The surrogate pair Modified UTF-8 wants, each half written as
+ // its own three-byte sequence.
+ const uint32_t rest = code - 0x10000;
+ append_three(out, 0xD800 + (rest >> 10));
+ append_three(out, 0xDC00 + (rest & 0x3FF));
+ }
}
return out;
}
+ static void append_three(std::string &out, uint32_t code) {
+ out += static_cast(0xE0 | (code >> 12));
+ out += static_cast(0x80 | ((code >> 6) & 0x3F));
+ out += static_cast(0x80 | (code & 0x3F));
+ }
+
void option(const char *name, const char *value) { mpv_set_option_string(mpv_, name, value); }
void observe(const char *name, uint64_t id, mpv_format format) { mpv_observe_property(mpv_, id, name, format); }
void set_string(const char *name, const std::string &value) { mpv_set_property_string(mpv_, name, value.c_str()); }
@@ -306,25 +513,64 @@ class Player {
if (start > 0.0) set_double("time-pos", start);
}
if (event->event_id == MPV_EVENT_END_FILE) {
+ const auto *end = static_cast(event->data);
std::lock_guard lock(state_mutex_);
+ // The end of the file `loadfile` is replacing, not of the one it
+ // is loading. Reporting it would hand every reader an episode
+ // that ended before it started.
+ if (state_.loading) continue;
state_.ended = true;
+ state_.end_reason = end ? end_reason_of(end->reason) : EndReason::Other;
} else if (event->event_id == MPV_EVENT_START_FILE) {
std::lock_guard lock(state_mutex_);
+ state_.loading = false;
state_.ended = false;
+ state_.end_reason = EndReason::None;
+ } else if (event->event_id == MPV_EVENT_SEEK) {
+ std::lock_guard lock(state_mutex_);
+ state_.seeking = true;
+ } else if (event->event_id == MPV_EVENT_PLAYBACK_RESTART) {
+ // The seek is *visibly* done here, not when it was issued.
+ std::lock_guard lock(state_mutex_);
+ state_.seeking = false;
+ } else if (event->event_id == MPV_EVENT_LOG_MESSAGE) {
+ record_log(*static_cast(event->data));
} else if (event->event_id == MPV_EVENT_PROPERTY_CHANGE) {
update_property(*static_cast(event->data));
}
}
}
+ /// Keep the first error of the open file, not the last.
+ ///
+ /// A failure cascades — one unreadable header produces a dozen follow-on
+ /// complaints — and the first line is the one that names the cause. Cleared
+ /// by [take_error], which is how the UI consumes it.
+ void record_log(const mpv_event_log_message &message) {
+ if (!message.text) return;
+ std::lock_guard lock(state_mutex_);
+ if (!error_.empty()) return;
+ error_.assign(message.text);
+ while (!error_.empty() && (error_.back() == '\n' || error_.back() == '\r')) error_.pop_back();
+ }
+
void update_property(const mpv_event_property &property) {
if (!property.data) return;
std::lock_guard lock(state_mutex_);
- if (!std::strcmp(property.name, "time-pos")) state_.position = *static_cast(property.data);
- else if (!std::strcmp(property.name, "duration")) state_.duration = *static_cast(property.data);
- else if (!std::strcmp(property.name, "volume")) state_.volume = *static_cast(property.data);
+ // `volume`, `mute` and `pause` belong to the core and carry across a
+ // load; the rest describe the open file, and a reading of them that
+ // arrives mid-load is the outgoing episode's clock under the incoming
+ // episode's name.
+ if (!std::strcmp(property.name, "volume")) state_.volume = *static_cast(property.data);
else if (!std::strcmp(property.name, "pause")) state_.paused = *static_cast(property.data);
else if (!std::strcmp(property.name, "mute")) state_.muted = *static_cast(property.data);
+ else if (state_.loading) return;
+ else if (!std::strcmp(property.name, "time-pos")) state_.position = *static_cast(property.data);
+ else if (!std::strcmp(property.name, "duration")) state_.duration = *static_cast(property.data);
+ else if (!std::strcmp(property.name, "video-params/dw")) state_.video_width = static_cast(*static_cast(property.data));
+ else if (!std::strcmp(property.name, "video-params/dh")) state_.video_height = static_cast(*static_cast(property.data));
+ else if (!std::strcmp(property.name, "paused-for-cache")) state_.buffering = *static_cast(property.data);
+ else if (!std::strcmp(property.name, "cache-buffering-state")) state_.cache_percent = static_cast(*static_cast(property.data));
}
static void render_update(void *opaque) noexcept {
@@ -336,6 +582,15 @@ class Player {
});
}
+ /// Owns EGL and the mpv render context for the life of the player.
+ ///
+ /// Both are set up here rather than on the first surface attach, and neither
+ /// depends on there being a window: a pbuffer is enough to make a context
+ /// current, and `vo_libmpv` only needs *some* consumer. That is what makes
+ /// audio playback with no surface — the screen off, the app backgrounded —
+ /// structurally possible; before this, `load` waited five seconds for a
+ /// render context that only a visible SurfaceView could ever create, and
+ /// then failed.
void render_loop() {
EGLDisplay display = EGL_NO_DISPLAY;
EGLContext context = EGL_NO_CONTEXT;
@@ -343,6 +598,7 @@ class Player {
EGLSurface pbuffer = EGL_NO_SURFACE;
EGLConfig config = nullptr;
ANativeWindow *window = nullptr;
+ if (initialize_egl(display, context, config, pbuffer)) initialize_renderer();
while (running_.load()) {
std::unique_lock lock(render_mutex_);
render_cv_.wait(lock, [this] { return !running_.load() || surface_changed_ || frame_ready_.load(); });
@@ -353,18 +609,31 @@ class Player {
if (window) ANativeWindow_release(window);
window = pending_window_;
pending_window_ = nullptr;
- if (window && display == EGL_NO_DISPLAY && !initialize_egl(display, context, config, pbuffer)) {
+ if (window && display == EGL_NO_DISPLAY) {
+ // EGL failed at startup and playback is audio-only. Keep the
+ // window rather than the surface: nothing here can retry.
ANativeWindow_release(window);
window = nullptr;
}
if (window) {
surface = eglCreateWindowSurface(display, config, window, nullptr);
eglMakeCurrent(display, surface, surface, context);
- initialize_renderer();
}
}
const bool draw = frame_ready_.exchange(false);
lock.unlock();
+ // With no window there is still a frame to consume. `vo_libmpv`
+ // expects its host to keep draining, and a video thread with no
+ // consumer stalls — which is why video came back out of sync with
+ // audio after the screen had been off.
+ if (draw && surface == EGL_NO_SURFACE && render_) {
+ eglMakeCurrent(display, pbuffer, pbuffer, context);
+ if (mpv_render_context_update(render_) & MPV_RENDER_UPDATE_FRAME) {
+ int skip = 1;
+ mpv_render_param params[] = {{MPV_RENDER_PARAM_SKIP_RENDERING, &skip}, {MPV_RENDER_PARAM_INVALID, nullptr}};
+ mpv_render_context_render(render_, params);
+ }
+ }
if (draw && surface != EGL_NO_SURFACE && render_) {
eglMakeCurrent(display, surface, surface, context);
if (mpv_render_context_update(render_) & MPV_RENDER_UPDATE_FRAME) {
@@ -394,18 +663,40 @@ class Player {
if (pending_window_) { ANativeWindow_release(pending_window_); pending_window_ = nullptr; }
}
+ /// All of EGL, or none of it.
+ ///
+ /// Leaving a display initialised behind a failed context is what made a
+ /// partial failure permanent: the retry guard is "is there a display", so a
+ /// half-built state reads as success forever and every later attach uses an
+ /// uninitialised config and `EGL_NO_CONTEXT`. Unwinding here keeps the guard
+ /// honest, and `eglGetError` says which step failed instead of leaving a
+ /// black screen with nothing in the log.
bool initialize_egl(EGLDisplay &display, EGLContext &context, EGLConfig &config, EGLSurface &pbuffer) {
+ const auto fail = [&](const char *step) {
+ __android_log_print(ANDROID_LOG_ERROR, "pstr-mpv", "%s failed: 0x%04x", step, eglGetError());
+ if (pbuffer != EGL_NO_SURFACE) eglDestroySurface(display, pbuffer);
+ if (context != EGL_NO_CONTEXT) eglDestroyContext(display, context);
+ if (display != EGL_NO_DISPLAY) eglTerminate(display);
+ display = EGL_NO_DISPLAY;
+ context = EGL_NO_CONTEXT;
+ pbuffer = EGL_NO_SURFACE;
+ config = nullptr;
+ return false;
+ };
display = eglGetDisplay(EGL_DEFAULT_DISPLAY);
- if (display == EGL_NO_DISPLAY || !eglInitialize(display, nullptr, nullptr)) return false;
+ if (display == EGL_NO_DISPLAY) return fail("eglGetDisplay");
+ if (!eglInitialize(display, nullptr, nullptr)) return fail("eglInitialize");
const EGLint attributes[] = {EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT, EGL_SURFACE_TYPE, EGL_WINDOW_BIT | EGL_PBUFFER_BIT, EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8, EGL_BLUE_SIZE, 8, EGL_NONE};
EGLint count = 0;
- if (!eglChooseConfig(display, attributes, &config, 1, &count) || count == 0) return false;
+ if (!eglChooseConfig(display, attributes, &config, 1, &count) || count == 0) return fail("eglChooseConfig");
const EGLint context_attributes[] = {EGL_CONTEXT_CLIENT_VERSION, 2, EGL_NONE};
context = eglCreateContext(display, config, EGL_NO_CONTEXT, context_attributes);
- if (context == EGL_NO_CONTEXT) return false;
+ if (context == EGL_NO_CONTEXT) return fail("eglCreateContext");
const EGLint pbuffer_attributes[] = {EGL_WIDTH, 1, EGL_HEIGHT, 1, EGL_NONE};
pbuffer = eglCreatePbufferSurface(display, config, pbuffer_attributes);
- return pbuffer != EGL_NO_SURFACE && eglMakeCurrent(display, pbuffer, pbuffer, context);
+ if (pbuffer == EGL_NO_SURFACE) return fail("eglCreatePbufferSurface");
+ if (!eglMakeCurrent(display, pbuffer, pbuffer, context)) return fail("eglMakeCurrent");
+ return true;
}
void initialize_renderer() {
@@ -426,6 +717,8 @@ class Player {
std::thread renderer_;
mutable std::mutex state_mutex_;
PlaybackState state_;
+ /// The first error mpv logged about the open file, until it is consumed.
+ std::string error_;
std::mutex render_mutex_;
std::condition_variable render_cv_;
ANativeWindow *pending_window_ = nullptr;
@@ -465,13 +758,17 @@ bool utf8(JNIEnv *env, jstring value, std::string &result) {
void reject_null_surface(JNIEnv *env) noexcept {
c_boundary([=] {
jclass type = env->FindClass("java/lang/IllegalArgumentException");
- if (type) env->ThrowNew(type, "surface must not be null");
+ if (!type) return;
+ env->ThrowNew(type, "surface must not be null");
+ // FindClass hands back a local reference, and this is called from a
+ // long-lived attached thread where the frame is not popped for us.
+ env->DeleteLocalRef(type);
});
}
} // namespace
-extern "C" JNIEXPORT jlong JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeCreate(JNIEnv *, jobject) noexcept {
+extern "C" JNIEXPORT jlong JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeCreate(JNIEnv *, jclass) noexcept {
return c_boundary(0, [] {
auto player = std::make_unique();
return player->valid() ? reinterpret_cast(player.release()) : 0;
@@ -513,6 +810,9 @@ extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHo
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeVolume(JNIEnv *, jobject, jlong h, jdouble value) noexcept {
c_boundary([=] { if (auto *p = from(h)) p->volume(value); });
}
+extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeSpeed(JNIEnv *, jobject, jlong h, jdouble value) noexcept {
+ c_boundary([=] { if (auto *p = from(h)) p->speed(value); });
+}
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeMute(JNIEnv *, jobject, jlong h, jboolean value) noexcept {
c_boundary([=] { if (auto *p = from(h)) p->mute(value == JNI_TRUE); });
}
@@ -527,19 +827,38 @@ extern "C" JNIEXPORT jdoubleArray JNICALL Java_io_narl_protonstream_playback_Nat
const auto state = from(h) ? from(h)->state() : PlaybackState{};
const jdouble values[] = {state.position, state.duration, state.volume,
state.paused ? 1.0 : 0.0, state.muted ? 1.0 : 0.0,
- state.ended ? 1.0 : 0.0};
- jdoubleArray result = env->NewDoubleArray(6);
+ state.ended ? 1.0 : 0.0,
+ static_cast(state.generation),
+ state.video_width, state.video_height,
+ static_cast(state.end_reason),
+ state.buffering ? 1.0 : 0.0, state.cache_percent,
+ state.seeking ? 1.0 : 0.0};
+ constexpr jsize kFields = 13;
+ jdoubleArray result = env->NewDoubleArray(kFields);
if (!result) return static_cast(nullptr);
- env->SetDoubleArrayRegion(result, 0, 6, values);
+ env->SetDoubleArrayRegion(result, 0, kFields, values);
if (env->ExceptionCheck()) return static_cast(nullptr);
return result;
});
}
+extern "C" JNIEXPORT jstring JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeTakeError(JNIEnv *env, jobject, jlong h) noexcept {
+ return c_boundary(nullptr, [=]() -> jstring {
+ auto *player = from(h);
+ if (!player) return nullptr;
+ const std::string error = player->take_error();
+ return error.empty() ? nullptr : string(env, error);
+ });
+}
extern "C" JNIEXPORT jstring JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeTracks(JNIEnv *env, jobject, jlong h) noexcept {
return c_boundary(nullptr, [=] {
return string(env, from(h) ? from(h)->tracks_json() : "[]");
});
}
+extern "C" JNIEXPORT jstring JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeChapters(JNIEnv *env, jobject, jlong h) noexcept {
+ return c_boundary(nullptr, [=] {
+ return string(env, from(h) ? from(h)->chapters_json() : "[]");
+ });
+}
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_pstrAndroidStreamRelease(JNIEnv *, jobject, jlong handle) noexcept {
c_boundary([=] { pstr_android_stream_release(static_cast(handle)); });
}
diff --git a/android/app/src/main/cpp/pstr_mpv.h b/android/app/src/main/cpp/pstr_mpv.h
index 08f3833..5e1e955 100644
--- a/android/app/src/main/cpp/pstr_mpv.h
+++ b/android/app/src/main/cpp/pstr_mpv.h
@@ -8,6 +8,9 @@ extern "C" {
#endif
int64_t pstr_android_stream_read(uint64_t handle, uint64_t offset, void *buffer, size_t length);
int64_t pstr_android_stream_size(uint64_t handle);
+/// Interrupts an in-flight `pstr_android_stream_read`. A cancel with no reader
+/// waiting is dropped, so this is safe to call speculatively.
+void pstr_android_stream_cancel(uint64_t handle);
void pstr_android_stream_release(uint64_t handle);
#ifdef __cplusplus
}
diff --git a/android/app/src/main/java/io/narl/protonstream/MainActivity.kt b/android/app/src/main/java/io/narl/protonstream/MainActivity.kt
index 18d9c30..ec52ebe 100644
--- a/android/app/src/main/java/io/narl/protonstream/MainActivity.kt
+++ b/android/app/src/main/java/io/narl/protonstream/MainActivity.kt
@@ -1,72 +1,151 @@
package io.narl.protonstream
import android.Manifest
-import android.content.pm.PackageManager
-import android.os.Bundle
-import android.os.Build
+import android.app.PendingIntent
import android.app.PictureInPictureParams
+import android.app.RemoteAction
+import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Intent
+import android.content.IntentFilter
import android.content.ServiceConnection
+import android.content.pm.PackageManager
+import android.content.res.Configuration
+import android.graphics.Rect
+import android.graphics.drawable.Icon
+import android.os.Build
+import android.os.Bundle
import android.os.IBinder
import android.util.Rational
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
-import io.narl.protonstream.ui.ProtonStreamApp
-import io.narl.protonstream.ui.theme.ProtonStreamTheme
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.snapshotFlow
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.lifecycleScope
+import io.narl.protonstream.playback.MpvPlaybackState
import io.narl.protonstream.playback.NativeMpvHost
import io.narl.protonstream.playback.PlaybackService
-import androidx.compose.runtime.mutableStateOf
import io.narl.protonstream.settings.SettingsStore
+import io.narl.protonstream.ui.ProtonStreamApp
+import io.narl.protonstream.ui.theme.ProtonStreamTheme
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
private val playerHost = mutableStateOf(null)
+ private val inPictureInPicture = mutableStateOf(false)
+ /**
+ * Whether a bind is outstanding, which is what has to be unbound.
+ *
+ * Set when `bindService` is called rather than when the connection arrives:
+ * an activity destroyed while the bind is still pending owes the system an
+ * `unbindService` just the same, and `onServiceConnected` may never run.
+ */
private var bound = false
private val playbackConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
playerHost.value = (service as PlaybackService.PlaybackBinder).host
- bound = true
}
override fun onServiceDisconnected(name: ComponentName?) {
playerHost.value = null
- bound = false
}
}
+
+ /**
+ * The transport behind the buttons Android draws over the PiP window.
+ *
+ * A broadcast rather than a service intent: PiP is one of the states in
+ * which a background service start is refused, and the receiver is
+ * registered for as long as the activity that owns the window exists.
+ */
+ private val pictureInPictureControls = object : BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: Intent?) {
+ val host = playerHost.value ?: return
+ when (intent?.getStringExtra(EXTRA_CONTROL)) {
+ CONTROL_PLAY -> host.setPaused(false)
+ CONTROL_PAUSE -> host.setPaused(true)
+ CONTROL_NEXT -> host.onSkipNext?.invoke()
+ CONTROL_PREVIOUS -> host.onSkipPrevious?.invoke()
+ }
+ }
+ }
+
private val notificationPermission = registerForActivityResult(
ActivityResultContracts.RequestPermission(),
) { /* Downloads remain usable; Android suppresses their notifications if denied. */ }
+ @OptIn(ExperimentalCoroutinesApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
- bindService(Intent(this, PlaybackService::class.java), playbackConnection, Context.BIND_AUTO_CREATE)
+ bound = bindService(
+ Intent(this, PlaybackService::class.java),
+ playbackConnection,
+ Context.BIND_AUTO_CREATE,
+ )
+ ContextCompat.registerReceiver(
+ this,
+ pictureInPictureControls,
+ IntentFilter(ACTION_PIP_CONTROL),
+ ContextCompat.RECEIVER_NOT_EXPORTED,
+ )
if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
) {
notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
}
+ // Auto-enter has to be armed *before* the gesture that would use it:
+ // building the params inside onUserLeaveHint is too late for the swipe
+ // home that gesture navigation sends, which never calls it. Re-register
+ // whenever the picture's shape or the transport's does.
+ lifecycleScope.launch {
+ snapshotFlow { playerHost.value }
+ .flatMapLatest { host -> host?.state ?: flowOf(null) }
+ .map { state -> PictureInPictureShape.of(state) }
+ .distinctUntilChanged()
+ .collect { shape -> runCatching { setPictureInPictureParams(shape.params()) } }
+ }
setContent {
ProtonStreamTheme {
- ProtonStreamApp(playerHost.value)
+ ProtonStreamApp(playerHost.value, inPictureInPicture.value)
}
}
}
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) {
- enterPictureInPictureMode(
- PictureInPictureParams.Builder().setAspectRatio(Rational(16, 9)).setAutoEnterEnabled(true).build(),
- )
+ 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() {
- if (bound) unbindService(playbackConnection)
+ runCatching { unregisterReceiver(pictureInPictureControls) }
+ if (bound) {
+ unbindService(playbackConnection)
+ bound = false
+ }
super.onDestroy()
}
@@ -80,4 +159,81 @@ class MainActivity : ComponentActivity() {
playerHost.value?.stop()
}
}
+
+ /** Everything the PiP window's shape depends on, and nothing that polls. */
+ private data class PictureInPictureShape(
+ val active: Boolean,
+ val paused: Boolean,
+ val width: Int,
+ val height: Int,
+ ) {
+ companion object {
+ fun of(state: MpvPlaybackState?): PictureInPictureShape = PictureInPictureShape(
+ active = state != null && state.duration > 0.0 && !state.ended,
+ paused = state?.paused != false,
+ width = state?.videoWidth?.toInt() ?: 0,
+ height = state?.videoHeight?.toInt() ?: 0,
+ )
+ }
+ }
+
+ private fun PictureInPictureShape.params(): PictureInPictureParams =
+ PictureInPictureParams.Builder()
+ .setAspectRatio(aspectRatio(width, height))
+ .setAutoEnterEnabled(active)
+ .setSeamlessResizeEnabled(false)
+ // The player owns the whole window, so the picture's rectangle is
+ // the window's. Without it the transition into PiP cross-fades from
+ // a screenshot instead of shrinking the video that is already there.
+ .setSourceRectHint(windowBounds())
+ .setActions(
+ listOf(
+ remoteAction(android.R.drawable.ic_media_previous, "Previous episode", CONTROL_PREVIOUS, 11),
+ if (paused) remoteAction(android.R.drawable.ic_media_play, "Play", CONTROL_PLAY, 12)
+ else remoteAction(android.R.drawable.ic_media_pause, "Pause", CONTROL_PAUSE, 13),
+ remoteAction(android.R.drawable.ic_media_next, "Next episode", CONTROL_NEXT, 14),
+ ),
+ )
+ .build()
+
+ private fun windowBounds(): Rect = window.decorView.let { Rect(0, 0, it.width, it.height) }
+
+ private fun remoteAction(icon: Int, label: String, control: String, requestCode: Int): RemoteAction =
+ RemoteAction(
+ Icon.createWithResource(this, icon),
+ label,
+ label,
+ PendingIntent.getBroadcast(
+ this,
+ requestCode,
+ Intent(ACTION_PIP_CONTROL).setPackage(packageName).putExtra(EXTRA_CONTROL, control),
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
+ ),
+ )
+
+ private companion object {
+ const val ACTION_PIP_CONTROL = "io.narl.protonstream.PIP_CONTROL"
+ const val EXTRA_CONTROL = "control"
+ const val CONTROL_PLAY = "play"
+ const val CONTROL_PAUSE = "pause"
+ const val CONTROL_NEXT = "next"
+ const val CONTROL_PREVIOUS = "previous"
+
+ /**
+ * Android refuses a window narrower than 1:2.39 or taller than 2.39:1,
+ * and refuses the request outright rather than clamping it — so a
+ * 2.40:1 scope release would take PiP away entirely.
+ */
+ fun aspectRatio(width: Int, height: Int): Rational {
+ if (width <= 0 || height <= 0) return Rational(16, 9)
+ val ratio = width.toDouble() / height
+ return when {
+ ratio > MAX_RATIO -> Rational(239, 100)
+ ratio < 1 / MAX_RATIO -> Rational(100, 239)
+ else -> Rational(width, height)
+ }
+ }
+
+ const val MAX_RATIO = 2.39
+ }
}
diff --git a/android/app/src/main/java/io/narl/protonstream/download/DownloadCoordinator.kt b/android/app/src/main/java/io/narl/protonstream/download/DownloadCoordinator.kt
index ebf8105..34c46df 100644
--- a/android/app/src/main/java/io/narl/protonstream/download/DownloadCoordinator.kt
+++ b/android/app/src/main/java/io/narl/protonstream/download/DownloadCoordinator.kt
@@ -1,37 +1,151 @@
package io.narl.protonstream.download
import android.content.Context
+import android.os.StatFs
import androidx.work.Constraints
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
+import androidx.work.OneTimeWorkRequest
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.workDataOf
import io.narl.protonstream.settings.SettingsStore
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
import uniffi.pstr_android.EpisodeRecord
object DownloadCoordinator {
- fun enqueue(context: Context, episode: EpisodeRecord) {
- enqueue(
- context,
- RetainedDownload(episode.shareId, episode.volumeId, episode.linkId, episode.label),
- ExistingWorkPolicy.KEEP,
- )
- }
+ /**
+ * Where bulk queueing runs.
+ *
+ * Callers are click handlers, and "Download show" on a long series is a
+ * WorkManager enqueue per episode — each of which writes to its own
+ * database. On the main thread that is an ANR waiting for a long enough
+ * series.
+ */
+ private val queueing = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+
+ fun enqueue(context: Context, episode: EpisodeRecord) = enqueue(context, listOf(episode))
fun resume(context: Context, download: RetainedDownload) {
enqueue(context, download, ExistingWorkPolicy.REPLACE)
}
private fun enqueue(context: Context, download: RetainedDownload, policy: ExistingWorkPolicy) {
- DownloadStateStore(context).put(
- download.copy(status = RetainedDownload.STATUS_QUEUED, error = null),
+ val queued = download.copy(status = RetainedDownload.STATUS_QUEUED, error = null)
+ DownloadStateStore(context).put(queued)
+ WorkManager.getInstance(context).enqueueUniqueWork(
+ workName(download.shareId, download.linkId),
+ policy,
+ request(queued, SettingsStore(context).wifiOnly),
)
- val request = OneTimeWorkRequestBuilder()
+ }
+
+ fun enqueue(context: Context, episodes: Iterable) {
+ val wanted = episodes.filterNot(EpisodeRecord::offline).toList()
+ if (wanted.isEmpty()) return
+ val application = context.applicationContext
+ queueing.launch {
+ val wifiOnly = SettingsStore(application).wifiOnly
+ val store = DownloadStateStore(application)
+ val manager = WorkManager.getInstance(application)
+ // Space is checked once for the whole request, against its total.
+ // Queueing a season that cannot fit and discovering it episode by
+ // episode at ENOSPC is how the app fills its own database's disk.
+ val room = roomFor(application, wanted.sumOf { it.size?.toLong() ?: 0L })
+ val downloads = wanted.map { episode ->
+ RetainedDownload(
+ episode.shareId,
+ episode.volumeId,
+ episode.linkId,
+ episode.label,
+ total = episode.size?.toLong() ?: 0L,
+ status = if (room) RetainedDownload.STATUS_QUEUED else RetainedDownload.STATUS_FAILED,
+ error = if (room) null else "Not enough free space",
+ )
+ }
+ store.putAll(downloads)
+ if (!room) return@launch
+ downloads.forEach { download ->
+ manager.enqueueUniqueWork(
+ workName(download.shareId, download.linkId),
+ ExistingWorkPolicy.KEEP,
+ request(download, wifiOnly),
+ )
+ }
+ }
+ }
+
+ /**
+ * Whether [bytes] plus working room will fit where offline files are kept.
+ *
+ * `setRequiresStorageNotLow` only holds work back at Android's own low-storage
+ * threshold, which a single episode can cross on its own. An unknown size —
+ * zero — is allowed through: refusing a download because the catalog has no
+ * size for it would be worse than the ENOSPC it is guarding against.
+ */
+ private fun roomFor(context: Context, bytes: Long): Boolean {
+ if (bytes <= 0) return true
+ val available = runCatching { StatFs(context.filesDir.path).availableBytes }.getOrNull() ?: return true
+ return available - bytes >= STORAGE_HEADROOM
+ }
+
+ /**
+ * Re-apply the network constraint to work that is already queued.
+ *
+ * Constraints are baked in at enqueue time, so turning "Wi-Fi only" on after
+ * queueing a season did nothing to that season — it downloaded over cellular
+ * on the way out of the house, which is the one moment the setting exists
+ * for. Running work is left alone: WorkManager stops it on its own when the
+ * new constraint stops being met.
+ */
+ fun applyNetworkPolicy(context: Context) {
+ val application = context.applicationContext
+ queueing.launch {
+ val wifiOnly = SettingsStore(application).wifiOnly
+ val manager = WorkManager.getInstance(application)
+ DownloadStateStore(application).records()
+ .filter { it.status == RetainedDownload.STATUS_QUEUED || it.status == RetainedDownload.STATUS_RUNNING }
+ .forEach { download ->
+ manager.enqueueUniqueWork(
+ workName(download.shareId, download.linkId),
+ ExistingWorkPolicy.REPLACE,
+ request(download, wifiOnly),
+ )
+ }
+ }
+ }
+
+ fun pause(context: Context, download: RetainedDownload) =
+ setStatus(context, download, RetainedDownload.STATUS_PAUSED)
+
+ fun cancel(context: Context, download: RetainedDownload) =
+ setStatus(context, download, RetainedDownload.STATUS_CANCELLED)
+
+ /**
+ * The status write goes before the work is cancelled, and through `update`
+ * rather than `put`: the running worker is writing progress into the same
+ * record, and a whole-record `put` from a screen's stale copy would both
+ * lose that progress and race the worker's own read-modify-write.
+ */
+ private fun setStatus(context: Context, download: RetainedDownload, status: String) {
+ DownloadStateStore(context).update(download.shareId, download.linkId) { stored ->
+ (stored ?: download).copy(status = status, error = null)
+ }
+ WorkManager.getInstance(context).cancelUniqueWork(workName(download.shareId, download.linkId))
+ }
+
+ private fun request(download: RetainedDownload, wifiOnly: Boolean): OneTimeWorkRequest =
+ OneTimeWorkRequestBuilder()
.setConstraints(
- Constraints.Builder().setRequiredNetworkType(
- requiredNetworkType(SettingsStore(context).wifiOnly),
- ).build(),
+ Constraints.Builder()
+ .setRequiredNetworkType(requiredNetworkType(wifiOnly))
+ // A download that fills the disk takes the app's own
+ // database down with it; the `.part` file makes waiting free.
+ .setRequiresStorageNotLow(true)
+ .build(),
)
.setInputData(
workDataOf(
@@ -45,26 +159,6 @@ object DownloadCoordinator {
.addTag("$EPISODE_TAG${download.label}")
.addTag(shareTag(download.shareId))
.build()
- WorkManager.getInstance(context).enqueueUniqueWork(
- workName(download.shareId, download.linkId),
- policy,
- request,
- )
- }
-
- fun enqueue(context: Context, episodes: Iterable) {
- episodes.filterNot(EpisodeRecord::offline).forEach { enqueue(context, it) }
- }
-
- fun pause(context: Context, download: RetainedDownload) {
- DownloadStateStore(context).put(download.copy(status = RetainedDownload.STATUS_PAUSED, error = null))
- WorkManager.getInstance(context).cancelUniqueWork(workName(download.shareId, download.linkId))
- }
-
- fun cancel(context: Context, download: RetainedDownload) {
- DownloadStateStore(context).put(download.copy(status = RetainedDownload.STATUS_CANCELLED))
- WorkManager.getInstance(context).cancelUniqueWork(workName(download.shareId, download.linkId))
- }
fun shareTag(shareId: String) = "$SHARE_TAG$shareId"
fun workName(shareId: String, linkId: String) = "offline-$shareId-$linkId"
@@ -72,6 +166,9 @@ object DownloadCoordinator {
const val TAG = "offline-download"
const val EPISODE_TAG = "offline-episode:"
private const val SHARE_TAG = "offline-share:"
+
+ /** Free space to leave behind a download, for the catalog and the caches. */
+ private const val STORAGE_HEADROOM = 256L * 1024 * 1024
}
internal fun requiredNetworkType(wifiOnly: Boolean): NetworkType =
diff --git a/android/app/src/main/java/io/narl/protonstream/download/DownloadStateStore.kt b/android/app/src/main/java/io/narl/protonstream/download/DownloadStateStore.kt
index 7a31900..b309bf7 100644
--- a/android/app/src/main/java/io/narl/protonstream/download/DownloadStateStore.kt
+++ b/android/app/src/main/java/io/narl/protonstream/download/DownloadStateStore.kt
@@ -35,18 +35,59 @@ class DownloadStateStore(context: Context) {
fun get(shareId: String, linkId: String): RetainedDownload? =
preferences.getString(key(shareId, linkId), null)?.let { runCatching { decode(it) }.getOrNull() }
- fun put(record: RetainedDownload) {
- preferences.edit().putString(record.key, encode(record)).commit()
+ fun put(record: RetainedDownload) = synchronized(WRITES) {
+ edit { putString(record.key, encode(record)) }
}
- fun remove(shareId: String, linkId: String) {
- preferences.edit().remove(key(shareId, linkId)).commit()
+ /**
+ * Read-modify-write one record, excluding every other writer.
+ *
+ * The worker's progress update reads the stored record to decide whether the
+ * viewer has paused, then writes it back. `pause()` is another writer, and
+ * without this a `put` landing between those two halves puts `RUNNING` back
+ * over `PAUSED` — after which the worker's own cancellation handler sees a
+ * status that is not `PAUSED` and records `CANCELLED`. The pause is simply
+ * lost, which is the visible bug.
+ *
+ * Returning null from [change] leaves the record alone.
+ */
+ fun update(shareId: String, linkId: String, change: (RetainedDownload?) -> RetainedDownload?) =
+ synchronized(WRITES) {
+ change(get(shareId, linkId))?.let { edit { putString(it.key, encode(it)) } }
+ }
+
+ /**
+ * Write a whole queue in one go.
+ *
+ * Queueing a season is one call, not one per episode: `commit()` per record
+ * on a 200-episode series was 200 synchronous fsyncs, and the callers are
+ * click handlers.
+ */
+ fun putAll(records: Collection) = synchronized(WRITES) {
+ edit { records.forEach { putString(it.key, encode(it)) } }
}
- fun removeShare(shareId: String) {
- val edit = preferences.edit()
- records().filter { it.shareId == shareId }.forEach { edit.remove(it.key) }
- edit.commit()
+ fun remove(shareId: String, linkId: String) = synchronized(WRITES) {
+ edit { remove(key(shareId, linkId)) }
+ }
+
+ /** Forget every retained download. Callers cancel the work first. */
+ fun clear() = synchronized(WRITES) { edit { clear() } }
+
+ fun removeShare(shareId: String) = synchronized(WRITES) {
+ edit { records().filter { it.shareId == shareId }.forEach { remove(it.key) } }
+ }
+
+ /**
+ * `apply()`, not `commit()`.
+ *
+ * The in-memory map is updated before this returns, so a read that follows a
+ * write in the same process still sees it; only the disk write is deferred,
+ * and losing the newest progress reading to a kill is not worth an fsync on
+ * whichever thread happened to be writing.
+ */
+ private inline fun edit(block: android.content.SharedPreferences.Editor.() -> Unit) {
+ preferences.edit().apply(block).apply()
}
private fun encode(record: RetainedDownload) = JSONObject()
@@ -78,5 +119,11 @@ class DownloadStateStore(context: Context) {
private companion object {
const val NAME = "offline_download_state"
+
+ /**
+ * One lock for the whole process. Instances of this class are created
+ * ad hoc from a `Context`, so the lock cannot live on one of them.
+ */
+ val WRITES = Any()
}
}
diff --git a/android/app/src/main/java/io/narl/protonstream/download/OfflineDownloadWorker.kt b/android/app/src/main/java/io/narl/protonstream/download/OfflineDownloadWorker.kt
index f5cc1be..b0b3c0f 100644
--- a/android/app/src/main/java/io/narl/protonstream/download/OfflineDownloadWorker.kt
+++ b/android/app/src/main/java/io/narl/protonstream/download/OfflineDownloadWorker.kt
@@ -4,6 +4,7 @@ import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.pm.ServiceInfo
import android.content.Context
+import android.os.SystemClock
import androidx.core.app.NotificationCompat
import androidx.work.CoroutineWorker
import androidx.work.Data
@@ -23,6 +24,7 @@ class OfflineDownloadWorker(
parameters: WorkerParameters,
) : CoroutineWorker(appContext, parameters) {
override suspend fun doWork(): Result {
+ createNotificationChannel()
setForeground(createForegroundInfo(0))
val shareId = inputData.getString(KEY_SHARE_ID) ?: return Result.failure(error("missing share"))
val volumeId = inputData.getString(KEY_VOLUME_ID) ?: return Result.failure(error("missing volume"))
@@ -35,20 +37,43 @@ class OfflineDownloadWorker(
store.put(retained)
val observer = object : DownloadObserver {
+ private var reportedAt = 0L
+ private var reportedPercent = -1
+
+ /**
+ * Called once per 4 MiB block — a dozen times a second on a fast
+ * link, each one a WorkManager database write, a notification post
+ * and a preferences write. Nothing the viewer can see changes that
+ * often, so a report is only made when the percentage moves or a
+ * second has passed. The final one is never suppressed.
+ */
override fun onProgress(downloaded: ULong, total: ULong) {
val percent = if (total == 0UL) 0 else ((downloaded * 100UL) / total).toInt()
+ val now = SystemClock.elapsedRealtime()
+ val complete = total > 0UL && downloaded >= total
+ if (!complete && percent == reportedPercent && now - reportedAt < PROGRESS_INTERVAL_MS) {
+ return
+ }
+ reportedAt = now
+ reportedPercent = percent
setProgressAsync(workDataOf(KEY_DOWNLOADED to downloaded.toLong(), KEY_TOTAL to total.toLong()))
setForegroundAsync(createForegroundInfo(percent))
- val requested = store.get(shareId, linkId)
- val requestedStatus = requested?.status
- retained = (requested ?: retained).copy(
- downloaded = downloaded.toLong(),
- total = total.toLong(),
- status = if (requestedStatus == RetainedDownload.STATUS_PAUSED ||
- requestedStatus == RetainedDownload.STATUS_CANCELLED
- ) requestedStatus else RetainedDownload.STATUS_RUNNING,
- )
- store.put(retained)
+ // Under the store's lock: `pause()` is a concurrent writer, and
+ // this read-modify-write used to put RUNNING back over the
+ // viewer's PAUSED and then report the download as cancelled.
+ store.update(shareId, linkId) { stored ->
+ val current = stored ?: retained
+ current.copy(
+ downloaded = downloaded.toLong(),
+ total = total.toLong(),
+ status = when (current.status) {
+ RetainedDownload.STATUS_PAUSED,
+ RetainedDownload.STATUS_CANCELLED,
+ -> current.status
+ else -> RetainedDownload.STATUS_RUNNING
+ },
+ ).also { retained = it }
+ }
}
override fun isCancelled(): Boolean = isStopped
@@ -62,9 +87,12 @@ class OfflineDownloadWorker(
},
onFailure = { failure ->
if (isStopped) {
- val current = store.get(shareId, linkId) ?: retained
- if (current.status != RetainedDownload.STATUS_PAUSED) {
- store.put(current.copy(status = RetainedDownload.STATUS_CANCELLED))
+ // A pause is also a stop. Under the lock, so the record read
+ // here is the one the write lands on.
+ store.update(shareId, linkId) { stored ->
+ val current = stored ?: retained
+ current.takeIf { it.status != RetainedDownload.STATUS_PAUSED }
+ ?.copy(status = RetainedDownload.STATUS_CANCELLED)
}
Result.failure(error("cancelled"))
} else if (runAttemptCount < MAX_RETRIES) {
@@ -79,11 +107,14 @@ class OfflineDownloadWorker(
)
}
- private fun createForegroundInfo(progress: Int): ForegroundInfo {
- val manager = applicationContext.getSystemService(NotificationManager::class.java)
- manager.createNotificationChannel(
+ /** Once per run, rather than once per 4 MiB block. */
+ private fun createNotificationChannel() {
+ applicationContext.getSystemService(NotificationManager::class.java).createNotificationChannel(
NotificationChannel(CHANNEL_ID, "Offline downloads", NotificationManager.IMPORTANCE_LOW),
)
+ }
+
+ private fun createForegroundInfo(progress: Int): ForegroundInfo {
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setSmallIcon(io.narl.protonstream.R.drawable.ic_launcher_foreground)
.setContentTitle("Making episode available offline")
@@ -109,6 +140,9 @@ class OfflineDownloadWorker(
const val KEY_TOTAL = "total"
private const val MAX_RETRIES = 3
private const val CHANNEL_ID = "offline-downloads"
+
+ /** Shortest gap between two identical-looking progress reports. */
+ private const val PROGRESS_INTERVAL_MS = 1_000L
}
}
diff --git a/android/app/src/main/java/io/narl/protonstream/native/KeystoreSecretStore.kt b/android/app/src/main/java/io/narl/protonstream/native/KeystoreSecretStore.kt
index 80ba313..ae56525 100644
--- a/android/app/src/main/java/io/narl/protonstream/native/KeystoreSecretStore.kt
+++ b/android/app/src/main/java/io/narl/protonstream/native/KeystoreSecretStore.kt
@@ -2,6 +2,7 @@ package io.narl.protonstream.native
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
+import android.security.keystore.KeyPermanentlyInvalidatedException
import android.security.keystore.KeyProperties
import android.util.Base64
import java.security.KeyStore
@@ -10,22 +11,44 @@ import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import uniffi.pstr_android.AndroidSecretStore
+import uniffi.pstr_android.BridgeException
-/** Encrypts share fragments and passwords before they reach app preferences. */
+/**
+ * Encrypts share fragments and passwords before they reach app preferences.
+ *
+ * Every failure here leaves as a [BridgeException]. Raw Java exceptions crossing
+ * the FFI have no mapping on the Rust side, and an unmapped callback exception
+ * is a panic on whichever thread was calling — including a tokio worker holding
+ * a download. What the caller does about it is a separate question; being told
+ * at all is this class's part.
+ */
class KeystoreSecretStore(context: Context) : AndroidSecretStore {
private val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
- override fun set(key: String, value: String) {
- val cipher = Cipher.getInstance(TRANSFORMATION)
- cipher.init(Cipher.ENCRYPT_MODE, encryptionKey())
- val encrypted = cipher.doFinal(value.toByteArray(Charsets.UTF_8))
- val payload = cipher.iv + encrypted
+ override fun set(key: String, value: String) = bridging("store credential") {
+ val payload = try {
+ seal(value)
+ } catch (invalidated: KeyPermanentlyInvalidatedException) {
+ // A lockscreen change or a device restore retires the key. Nothing
+ // it sealed can be read again — and nothing new can be sealed with
+ // it either, so re-entering a link would fail here rather than
+ // restoring access. Everything the old key held is already lost, so
+ // replacing it costs nothing that is not gone.
+ keyStore.deleteEntry(KEY_ALIAS)
+ seal(value)
+ }
check(preferences.edit().putString(key, Base64.encodeToString(payload, Base64.NO_WRAP)).commit())
}
- override fun get(key: String): String? {
- val encoded = preferences.getString(key, null) ?: return null
+ private fun seal(value: String): ByteArray {
+ val cipher = Cipher.getInstance(TRANSFORMATION)
+ cipher.init(Cipher.ENCRYPT_MODE, encryptionKey())
+ return cipher.iv + cipher.doFinal(value.toByteArray(Charsets.UTF_8))
+ }
+
+ override fun get(key: String): String? = bridging("read credential") {
+ val encoded = preferences.getString(key, null) ?: return@bridging null
val payload = Base64.decode(encoded, Base64.NO_WRAP)
require(payload.size > IV_SIZE) { "Invalid encrypted credential" }
val cipher = Cipher.getInstance(TRANSFORMATION)
@@ -34,13 +57,23 @@ class KeystoreSecretStore(context: Context) : AndroidSecretStore {
encryptionKey(),
GCMParameterSpec(TAG_BITS, payload.copyOfRange(0, IV_SIZE)),
)
- return cipher.doFinal(payload.copyOfRange(IV_SIZE, payload.size)).toString(Charsets.UTF_8)
+ cipher.doFinal(payload.copyOfRange(IV_SIZE, payload.size)).toString(Charsets.UTF_8)
}
- override fun delete(key: String) {
+ override fun delete(key: String) = bridging("delete credential") {
check(preferences.edit().remove(key).commit())
}
+ private fun bridging(action: String, block: () -> T): T = try {
+ block()
+ } catch (error: BridgeException) {
+ throw error
+ } catch (error: Exception) {
+ // The message, never the stack trace: this reaches a snackbar, and the
+ // trace is both unreadable there and a description of app internals.
+ throw BridgeException.Failure("Could not $action: ${error.message ?: error::class.java.simpleName}")
+ }
+
private fun encryptionKey(): SecretKey {
(keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it }
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").run {
diff --git a/android/app/src/main/java/io/narl/protonstream/native/NativeRuntime.kt b/android/app/src/main/java/io/narl/protonstream/native/NativeRuntime.kt
index 3d544c8..463ef0b 100644
--- a/android/app/src/main/java/io/narl/protonstream/native/NativeRuntime.kt
+++ b/android/app/src/main/java/io/narl/protonstream/native/NativeRuntime.kt
@@ -12,14 +12,23 @@ object NativeRuntime {
private lateinit var appContext: Context
@Volatile private var instance: AndroidEngine? = null
+ /**
+ * Whether Android's certificate verifier came up.
+ *
+ * False means every HTTPS request will fail validation, which otherwise
+ * surfaces much later as an unexplained certificate error against a share.
+ */
+ @Volatile var tlsReady: Boolean = false
+ private set
+
fun initialize(context: Context) {
System.loadLibrary("pstr_android")
- initTls(context)
+ tlsReady = initTls(context)
appContext = context.applicationContext
}
/** Initializes Android's certificate verifier before Proton networking. */
- private external fun initTls(context: Context)
+ private external fun initTls(context: Context): Boolean
suspend fun engine(): AndroidEngine = withContext(Dispatchers.IO) {
instance ?: synchronized(this@NativeRuntime) {
diff --git a/android/app/src/main/java/io/narl/protonstream/playback/LibmpvPlayerSurface.kt b/android/app/src/main/java/io/narl/protonstream/playback/LibmpvPlayerSurface.kt
deleted file mode 100644
index ae383a6..0000000
--- a/android/app/src/main/java/io/narl/protonstream/playback/LibmpvPlayerSurface.kt
+++ /dev/null
@@ -1,343 +0,0 @@
-package io.narl.protonstream.playback
-
-import android.view.Surface
-import android.view.SurfaceHolder
-import android.view.SurfaceView
-import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.heightIn
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.items
-import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.fadeIn
-import androidx.compose.animation.fadeOut
-import androidx.compose.foundation.clickable
-import androidx.compose.material3.AlertDialog
-import androidx.compose.material3.Button
-import androidx.compose.material3.OutlinedButton
-import androidx.compose.material3.Slider
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.PlayArrow
-import androidx.compose.material.icons.filled.Pause
-import androidx.compose.material.icons.filled.Fullscreen
-import androidx.compose.material.icons.filled.FullscreenExit
-import androidx.compose.material.icons.filled.VolumeUp
-import androidx.compose.material.icons.filled.VolumeOff
-import androidx.compose.material.icons.filled.Subtitles
-import androidx.compose.material.icons.filled.Audiotrack
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.runtime.collectAsState
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.viewinterop.AndroidView
-import io.narl.protonstream.native.NativeRuntime
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import uniffi.pstr_android.AndroidEngine
-import uniffi.pstr_android.AndroidStream
-import uniffi.pstr_android.EpisodeRecord
-import kotlin.math.roundToInt
-
-/** Exact seekable stream contract a bundled libmpv JNI adapter must consume. */
-class RustStreamSession(
- private val episode: EpisodeRecord,
-) {
- private var engine: AndroidEngine? = null
- private var stream: AndroidStream? = null
- private var handle: ULong? = null
- private var streamSize: ULong? = null
-
- suspend fun open(): RustStreamSession = withContext(Dispatchers.IO) {
- val openedEngine = NativeRuntime.engine()
- engine = openedEngine
- stream = openedEngine.openStream(episode.shareId, episode.volumeId, episode.linkId).also {
- streamSize = it.size()
- handle = it.nativeHandle()
- }
- this@RustStreamSession
- }
-
- fun nativeHandle(): ULong = checkNotNull(handle) { "stream is not published" }
-
- fun size(): ULong = checkNotNull(streamSize) { "stream is not open" }
-
- fun releaseNative(host: LibmpvHost) {
- handle?.let(host::releaseNativeStream)
- handle = null
- streamSize = null
- stream = null
- }
-
- /** The native player now owns the published token until stop/end/close. */
- fun transferToPlayer() {
- handle = null
- streamSize = null
- stream = null
- }
-
- suspend fun closeEngine() = withContext(Dispatchers.IO) {
- engine?.releaseStream(episode.shareId, episode.volumeId, episode.linkId)
- engine = null
- }
-}
-
-/** Implement only in the variant that bundles a pinned libmpv native artifact. */
-interface LibmpvHost {
- fun attachSurface(surface: Surface)
- fun detachSurface()
- /** Consumes the opaque handle through pstr_android_stream_{read,size}. */
- suspend fun play(
- nativeHandle: ULong,
- size: ULong,
- startPosition: Double = 0.0,
- audioLanguage: String? = null,
- subtitleLanguage: String? = null,
- subtitles: Boolean = true,
- )
- /** Calls pstr_android_stream_release exactly once for a published handle. */
- fun releaseNativeStream(nativeHandle: ULong)
-}
-
-private data class PlaybackStartup(
- val nativeHandle: ULong,
- val size: ULong,
- val startPosition: Double,
- val audioLanguage: String?,
- val subtitleLanguage: String?,
- val subtitles: Boolean,
-)
-
-@Composable
-fun LibmpvPlayerSurface(
- titleKey: String,
- episode: EpisodeRecord,
- host: LibmpvHost? = null,
- isFullscreen: Boolean = false,
- onToggleFullscreen: () -> Unit = {}
-) {
- if (host == null) {
- Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
- Text("Playback is unavailable: this build does not include libmpv.", color = Color.White)
- }
- return
- }
-
- val scope = rememberCoroutineScope()
- var playbackError by remember(sessionKey(episode)) { mutableStateOf(null) }
- val session = remember(episode.shareId, episode.linkId) {
- RustStreamSession(episode)
- }
- LaunchedEffect(session, host) {
- runCatching {
- val startup = withContext(Dispatchers.IO) {
- session.open()
- val engine = NativeRuntime.engine()
- val preferences = engine.titleTrackPreferences(titleKey)
- val watch = engine.watchState(episode.shareId, episode.linkId)
- PlaybackStartup(
- nativeHandle = session.nativeHandle(),
- size = session.size(),
- startPosition = watch?.positionSecs ?: 0.0,
- audioLanguage = preferences.audioLanguage,
- subtitleLanguage = preferences.subtitleLanguage,
- subtitles = preferences.subtitles,
- )
- }
- host.play(
- startup.nativeHandle, startup.size, startup.startPosition,
- startup.audioLanguage, startup.subtitleLanguage, startup.subtitles,
- )
- session.transferToPlayer()
- }.onFailure { playbackError = it.message ?: "Playback failed" }
- }
- DisposableEffect(session, host) {
- onDispose {
- host.detachSurface()
- session.releaseNative(host)
- scope.launch { session.closeEngine() }
- }
- }
-
- var showControls by remember { mutableStateOf(true) }
- LaunchedEffect(showControls) {
- if (showControls) {
- delay(4000)
- showControls = false
- }
- }
-
- Box(Modifier.fillMaxSize().clickable(
- interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() },
- indication = null,
- onClick = { showControls = !showControls }
- )) {
- AndroidView(
- modifier = Modifier.fillMaxSize(),
- factory = { context ->
- SurfaceView(context).apply {
- holder.addCallback(object : SurfaceHolder.Callback {
- override fun surfaceCreated(holder: SurfaceHolder) = host.attachSurface(holder.surface)
- override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) = host.attachSurface(holder.surface)
- override fun surfaceDestroyed(holder: SurfaceHolder) = host.detachSurface()
- })
- }
- },
- )
- AnimatedVisibility(
- visible = showControls,
- modifier = Modifier.align(Alignment.BottomCenter),
- enter = fadeIn(),
- exit = fadeOut()
- ) {
- if (host is NativeMpvHost) PlayerControls(
- titleKey,
- episode,
- host,
- isFullscreen,
- onToggleFullscreen
- )
- }
- playbackError?.let { message ->
- Text(message, color = Color.White, modifier = Modifier.align(Alignment.Center).background(Color.Black.copy(alpha = 0.8f)).padding(16.dp))
- }
- }
-}
-
-private fun sessionKey(episode: EpisodeRecord) = "${episode.shareId}:${episode.linkId}"
-
-@Composable
-private fun PlayerControls(
- titleKey: String,
- episode: EpisodeRecord,
- host: NativeMpvHost,
- isFullscreen: Boolean,
- onToggleFullscreen: () -> Unit,
- modifier: Modifier = Modifier
-) {
- val state by host.state.collectAsState()
- val tracks by host.tracks.collectAsState()
- val scope = rememberCoroutineScope()
- var choosing by remember { mutableStateOf(null) }
- LaunchedEffect(state.position.roundToInt() / 5) {
- if (state.duration > 0.0) withContext(Dispatchers.IO) {
- NativeRuntime.engine().saveWatchState(
- episode.shareId, episode.linkId, state.position.coerceAtMost(state.duration), state.duration,
- state.ended || state.position >= state.duration * 0.9,
- )
- }
- }
- Column(modifier.fillMaxWidth().background(Color.Black.copy(alpha = 0.68f)).padding(12.dp)) {
- Slider(
- value = state.position.toFloat().coerceIn(0f, state.duration.toFloat().coerceAtLeast(1f)),
- onValueChange = { host.seek(it.toDouble()) },
- valueRange = 0f..state.duration.toFloat().coerceAtLeast(1f),
- )
- Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
- IconButton(onClick = { host.setPaused(!state.paused) }) {
- Icon(if (state.paused) Icons.Default.PlayArrow else Icons.Default.Pause, contentDescription = "Play/Pause", tint = Color.White)
- }
- Text("${formatTime(state.position)} / ${formatTime(state.duration)}", color = Color.White)
- Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- IconButton(onClick = { host.setMuted(!state.muted) }) {
- Icon(if (state.muted) Icons.Default.VolumeOff else Icons.Default.VolumeUp, contentDescription = "Mute", tint = Color.White)
- }
- IconButton(onClick = onToggleFullscreen) {
- Icon(if (isFullscreen) Icons.Default.FullscreenExit else Icons.Default.Fullscreen, contentDescription = "Fullscreen", tint = Color.White)
- }
- }
- }
- Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
- IconButton(onClick = { choosing = "audio" }) {
- Icon(Icons.Default.Audiotrack, contentDescription = "Audio", tint = Color.White)
- }
- IconButton(onClick = { choosing = "sub" }) {
- Icon(Icons.Default.Subtitles, contentDescription = "Subtitles", tint = Color.White)
- }
- Text(
- "Audio: ${tracks.firstOrNull { it.type == "audio" && it.selected }?.label ?: "default"} • " +
- "Subtitles: ${tracks.firstOrNull { it.type == "sub" && it.selected }?.label ?: "off"}",
- color = Color.White,
- style = MaterialTheme.typography.bodySmall
- )
- }
- }
- choosing?.let { type ->
- TrackChooser(
- type = type,
- tracks = tracks.filter { it.type == type },
- onDismiss = { choosing = null },
- onSelect = { selected ->
- host.selectTrack(selected)
- choosing = null
- scope.launch(Dispatchers.IO) {
- val engine = NativeRuntime.engine()
- val old = engine.titleTrackPreferences(titleKey)
- engine.setTitleTrackPreferences(
- titleKey,
- if (type == "audio") old.copy(audioLanguage = selected?.language?.takeIf(String::isNotBlank))
- else old.copy(
- subtitleLanguage = selected?.language?.takeIf(String::isNotBlank),
- subtitles = selected != null,
- ),
- )
- }
- },
- )
- }
-}
-
-@Composable
-private fun TrackChooser(
- type: String,
- tracks: List,
- onDismiss: () -> Unit,
- onSelect: (MpvTrack?) -> Unit,
-) {
- AlertDialog(
- onDismissRequest = onDismiss,
- title = { Text(if (type == "audio") "Audio track" else "Subtitle track") },
- text = {
- LazyColumn(Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
- if (type == "sub") {
- item {
- OutlinedButton(onClick = { onSelect(null) }, Modifier.fillMaxWidth()) {
- Text(if (tracks.none(MpvTrack::selected)) "✓ Off" else "Off")
- }
- }
- }
- items(tracks, key = MpvTrack::id) { track ->
- OutlinedButton(onClick = { onSelect(track) }, Modifier.fillMaxWidth()) {
- Text(if (track.selected) "✓ ${track.label}" else track.label)
- }
- }
- }
- },
- confirmButton = { Button(onClick = onDismiss) { Text("Close") } },
- )
-}
-
-private fun formatTime(seconds: Double): String {
- val total = seconds.coerceAtLeast(0.0).roundToInt()
- return "%d:%02d".format(total / 60, total % 60)
-}
diff --git a/android/app/src/main/java/io/narl/protonstream/playback/NativeMpvHost.kt b/android/app/src/main/java/io/narl/protonstream/playback/NativeMpvHost.kt
index ce0675d..b8ccc74 100644
--- a/android/app/src/main/java/io/narl/protonstream/playback/NativeMpvHost.kt
+++ b/android/app/src/main/java/io/narl/protonstream/playback/NativeMpvHost.kt
@@ -1,6 +1,7 @@
package io.narl.protonstream.playback
import android.view.Surface
+import io.narl.protonstream.ui.ThumbnailSource
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -16,6 +17,31 @@ import kotlinx.coroutines.withContext
import org.json.JSONArray
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
+import uniffi.pstr_android.ChapterPlan
+import uniffi.pstr_android.ChapterRecord
+import uniffi.pstr_android.chapterPlan
+
+/**
+ * Why the open file ended, mirroring `pstr_player::EndReason` so both clients
+ * agree on what "finished" means.
+ *
+ * Only [Eof] is an episode watched to the end. Treating the others as one — a
+ * stop, a shutdown, a failed demux — is what marks an episode that never played
+ * as watched and autoplays past it.
+ */
+enum class EndReason {
+ None,
+ Eof,
+ Stopped,
+ Quit,
+ Failed,
+ Other,
+ ;
+
+ companion object {
+ fun of(value: Double): EndReason = entries.getOrElse(value.toInt()) { Other }
+ }
+}
data class MpvPlaybackState(
val position: Double = 0.0,
@@ -24,6 +50,53 @@ data class MpvPlaybackState(
val paused: Boolean = true,
val muted: Boolean = false,
val ended: Boolean = false,
+ /** Meaningful only while [ended]; [EndReason.None] otherwise. */
+ val endReason: EndReason = EndReason.None,
+ /**
+ * Which episode this reading belongs to, or null before the first poll of a
+ * newly loaded file. Everything that *writes* per-episode state — resume
+ * position above all — must check this: one host serves every episode, and
+ * a reading attributed to the wrong one resumes the next episode where the
+ * last one stopped.
+ */
+ val media: String? = null,
+ /** The picture's display size, or zero until mpv has decoded enough to know. */
+ val videoWidth: Double = 0.0,
+ val videoHeight: Double = 0.0,
+ /**
+ * mpv has run out of buffered data and stopped to refill.
+ *
+ * Not the same as [paused], which is the viewer's doing. A frozen picture
+ * that nothing explains is indistinguishable from a hung app, which is what
+ * a network-backed stream looked like before this existed.
+ */
+ val buffering: Boolean = false,
+ /** How full the demuxer cache is while [buffering], 0–100. */
+ val cachePercent: Double = 0.0,
+ /** A seek has been issued but playback has not resumed at the new position. */
+ val seeking: Boolean = false,
+) {
+ /** Whether the picture is stopped for a reason the viewer did not choose. */
+ val stalled: Boolean get() = (buffering || seeking) && !paused && !ended
+}
+
+/**
+ * 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.
+ */
+data class NowPlaying(
+ val media: String,
+ val title: String,
+ val show: String?,
+ val detail: String?,
+ val artworkUrl: String?,
+ /** The file to pull a Proton thumbnail from when the provider has no poster. */
+ val artworkFile: ThumbnailSource?,
+ val hasPrevious: Boolean,
+ val hasNext: Boolean,
)
data class MpvTrack(
@@ -38,21 +111,61 @@ data class MpvTrack(
}
/** Process-local owner of the libmpv core. PlaybackService owns its lifetime. */
-class NativeMpvHost : LibmpvHost, AutoCloseable {
- private val nativeHandle = nativeCreate().also { check(it != 0L) { "libmpv initialization failed" } }
+class NativeMpvHost private constructor(private val nativeHandle: Long) : LibmpvHost, AutoCloseable {
private val lifecycle = ReentrantLock()
@Volatile private var closed = false
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val mutableState = MutableStateFlow(MpvPlaybackState())
private val mutableTracks = MutableStateFlow(emptyList())
+ private val mutableChapters = MutableStateFlow(ChapterPlan(emptyList(), null))
+ private val mutableNowPlaying = MutableStateFlow(null)
+ private val mutableAdvancing = MutableStateFlow(false)
+ private val mutableProblem = MutableStateFlow(null)
private var poller: Job? = null
- private var currentStreamHandle: Long? = null
+ /** The episode key and load generation the native player currently holds. */
+ @Volatile private var currentMedia: String? = null
+ @Volatile private var currentGeneration: Double = -1.0
+ @Volatile private var plannedFor: Pair? = null
var onPlaybackStarted: (() -> Unit)? = null
var onStateChanged: ((MpvPlaybackState) -> Unit)? = null
var onExplicitStop: (() -> Unit)? = null
+ /**
+ * 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
+ * run on the main thread, which is where the player's state lives.
+ */
+ var onSkipPrevious: (() -> Unit)? = null
+ var onSkipNext: (() -> Unit)? = null
val state: StateFlow = mutableState.asStateFlow()
val tracks: StateFlow> = mutableTracks.asStateFlow()
+ /** The open file's chapters, already resolved into openings and endings. */
+ val chapters: StateFlow = mutableChapters.asStateFlow()
+ /** The episode the player has open, for every surface that has to name it. */
+ val nowPlaying: StateFlow = mutableNowPlaying.asStateFlow()
+ /**
+ * True while the player is between episodes.
+ *
+ * The end of one episode and the start of the next are separated by a
+ * network round trip — opening the next revision's stream. The foreground
+ * service must not retire in that gap: with no foreground state left, the
+ * `startForeground` on the other side is a background start, which API 31+
+ * refuses outright.
+ */
+ val advancing: StateFlow = mutableAdvancing.asStateFlow()
+ /**
+ * What mpv last complained about, until the viewer dismisses it.
+ *
+ * Errors mpv reports are about the *file* — an unreadable header, a codec it
+ * has no decoder for — and reach nothing else: the play call has already
+ * returned successfully by the time they happen.
+ */
+ val problem: StateFlow = mutableProblem.asStateFlow()
+
+ /** Dismiss the current [problem]. */
+ fun clearProblem() { mutableProblem.value = null }
val hasActivePlayback: Boolean get() = mutableState.value.duration > 0.0 && !mutableState.value.ended
override fun attachSurface(surface: Surface) {
@@ -64,6 +177,7 @@ class NativeMpvHost : LibmpvHost, AutoCloseable {
}
override suspend fun play(
+ media: String,
nativeHandle: ULong,
size: ULong,
startPosition: Double,
@@ -72,69 +186,172 @@ class NativeMpvHost : LibmpvHost, AutoCloseable {
subtitles: Boolean,
) {
check(size > 0uL) { "Cannot play an empty stream" }
- withContext(Dispatchers.IO) {
- val loaded = withOpenHandle {
- nativeLoad(it, nativeHandle.toLong(), startPosition, audioLanguage, subtitleLanguage, subtitles).also { loaded ->
- if (loaded) {
- currentStreamHandle?.let(::pstrAndroidStreamRelease)
- currentStreamHandle = nativeHandle.toLong()
+ try {
+ withContext(Dispatchers.IO) {
+ val loaded = withOpenHandle {
+ nativeLoad(it, nativeHandle.toLong(), startPosition, audioLanguage, subtitleLanguage, subtitles).also { loaded ->
+ if (loaded) {
+ // The outgoing stream is released by mpv closing it,
+ // not from here: `loadfile` is asynchronous and its
+ // demuxer may still be reading. `stream_close` in
+ // `pstr_mpv.cpp` owns that release.
+ //
+ // The outgoing file's readings describe an episode
+ // that is no longer open. Retire them here as well
+ // as natively, so nothing observes them even once.
+ currentMedia = media
+ currentGeneration = withOpenHandle(::nativeState)?.generation() ?: -1.0
+ mutableState.value = MpvPlaybackState(
+ position = startPosition,
+ volume = mutableState.value.volume,
+ muted = mutableState.value.muted,
+ media = media,
+ )
+ mutableProblem.value = null
+ mutableTracks.value = emptyList()
+ mutableChapters.value = ChapterPlan(emptyList(), null)
+ plannedFor = null
+ }
}
+ } ?: false
+ check(loaded) {
+ "libmpv rejected the stream or no video surface was available"
}
- } ?: false
- check(loaded) {
- "libmpv rejected the stream or no video surface was available"
}
- }
- withOpenHandle {
- onPlaybackStarted?.invoke()
- startPolling()
+ withOpenHandle {
+ onPlaybackStarted?.invoke()
+ startPolling()
+ }
+ } finally {
+ // Whether the next episode arrived or failed to, the gap the
+ // foreground service was holding open is over.
+ mutableAdvancing.value = false
}
}
- fun setPaused(paused: Boolean) { withOpenHandle { nativePause(it, paused) } }
+ /**
+ * Declare that the player is walking to another episode.
+ *
+ * Called before the stream for the next one is opened, and cleared when
+ * [play] returns either way — the window this covers is the one in which
+ * nothing is loaded and the service would otherwise conclude playback is
+ * finished.
+ */
+ fun beginTransition() { mutableAdvancing.value = true }
+
+ /** Names the open episode for the media session, its notification and PiP. */
+ fun publish(playing: NowPlaying?) { mutableNowPlaying.value = playing }
+
+ fun setPaused(paused: Boolean) {
+ // The native side writes the flag through, so every transport can flip
+ // now instead of on the next poll a quarter second from here.
+ val next = withOpenHandle {
+ nativePause(it, paused)
+ mutableState.value.copy(paused = paused).also { state -> mutableState.value = state }
+ } ?: return
+ onStateChanged?.invoke(next)
+ }
+
fun seek(position: Double) { withOpenHandle { nativeSeek(it, position) } }
fun setVolume(volume: Double) { withOpenHandle { nativeVolume(it, volume) } }
fun setMuted(muted: Boolean) { withOpenHandle { nativeMute(it, muted) } }
+
+ /** Playback rate, 1.0 being the file's own. Clamped native-side. */
+ fun setSpeed(speed: Double) { withOpenHandle { nativeSpeed(it, speed) } }
fun selectTrack(track: MpvTrack?) {
val audio = track?.type == "audio"
withOpenHandle { nativeSelectTrack(it, audio, track?.id ?: -1) }
refreshTracks()
}
- /** Explicit user stop: stop media, release its stream, and retire the foreground service. */
+ /**
+ * Explicit user stop: stop media and retire the foreground service.
+ *
+ * The stream behind the open file is released by mpv closing it, which
+ * `stop` causes; releasing it from here would race that close.
+ */
fun stop() {
val callback = withOpenHandle {
nativeStop(it)
- currentStreamHandle?.let(::pstrAndroidStreamRelease)
- currentStreamHandle = null
+ // Nothing loaded means nothing to attribute a reading to: a save
+ // issued after this must not land on the episode just stopped.
+ currentMedia = null
+ mutableAdvancing.value = false
+ mutableChapters.value = ChapterPlan(emptyList(), null)
+ mutableNowPlaying.value = null
onExplicitStop
}
callback?.invoke()
}
- internal fun releaseEndedStream() {
- withOpenHandle { currentStreamHandle?.let(::pstrAndroidStreamRelease); currentStreamHandle = null }
- }
-
override fun releaseNativeStream(nativeHandle: ULong) = pstrAndroidStreamRelease(nativeHandle.toLong())
private fun startPolling() {
if (poller?.isActive == true) return
- poller = scope.launch {
+ // Off the main thread deliberately: `nativeState` and `nativeTracks`
+ // are synchronous `mpv_get_property` calls — five per track for the
+ // latter — that contend on the core lock the demuxer holds during a
+ // fetch. A poll that blocks there blocks the UI thread with it.
+ poller = scope.launch(Dispatchers.Default) {
var tick = 0
while (isActive) {
val values = withOpenHandle(::nativeState) ?: break
- if (values.size >= 6) {
- val next = MpvPlaybackState(values[0], values[1], values[2], values[3] != 0.0, values[4] != 0.0, values[5] != 0.0)
+ if (values.size >= FIELDS) {
+ val next = MpvPlaybackState(
+ values[0], values[1], values[2],
+ values[3] != 0.0, values[4] != 0.0, values[5] != 0.0,
+ // Only a reading of the file this host was last asked
+ // to load carries its episode's name.
+ media = currentMedia.takeIf { values.generation() == currentGeneration },
+ videoWidth = values[WIDTH],
+ videoHeight = values[HEIGHT],
+ endReason = EndReason.of(values[END_REASON]),
+ buffering = values[BUFFERING] != 0.0,
+ cachePercent = values[CACHE_PERCENT],
+ seeking = values[SEEKING] != 0.0,
+ )
mutableState.value = next
- onStateChanged?.invoke(next)
+ // The listeners drive the media session and the player UI,
+ // both of which are the main thread's.
+ withContext(Dispatchers.Main) { onStateChanged?.invoke(next) }
+ refreshChapters(next)
}
+ withOpenHandle(::nativeTakeError)?.let { mutableProblem.value = it }
if (tick++ % 4 == 0) refreshTracks()
delay(250)
}
}
}
+ /**
+ * Re-read the chapters when there is a reason to: a different file, or a
+ * duration that has only just arrived. Both change the answer — the length
+ * of a chapter is what settles whether `Intro` is an opening or ten minutes
+ * of story — and neither changes again after that, so this settles quickly
+ * and then costs one comparison per poll.
+ */
+ private fun refreshChapters(state: MpvPlaybackState) {
+ val generation = currentGeneration
+ val wanted = state.duration to generation.toRawBits()
+ if (plannedFor == wanted) return
+ runCatching {
+ val encoded = withOpenHandle(::nativeChapters) ?: return
+ val values = JSONArray(encoded)
+ List(values.length()) { index ->
+ values.getJSONObject(index).run {
+ ChapterRecord(
+ index = getLong("index"),
+ title = optString("title").takeIf(String::isNotBlank),
+ start = getDouble("start"),
+ )
+ }
+ }
+ }.onSuccess { chapters ->
+ plannedFor = wanted
+ mutableChapters.value = chapterPlan(chapters, state.duration.takeIf { it > 0.0 })
+ }
+ }
+
private fun refreshTracks() {
runCatching {
val encoded = withOpenHandle(::nativeTracks) ?: return
@@ -149,16 +366,28 @@ class NativeMpvHost : LibmpvHost, AutoCloseable {
override fun close() {
scope.cancel()
- lifecycle.withLock {
- if (closed) return@withLock
+ val destroy = lifecycle.withLock {
+ if (closed) return@withLock false
closed = true
onPlaybackStarted = null
onStateChanged = null
onExplicitStop = null
- nativeDestroy(nativeHandle)
- currentStreamHandle?.let(::pstrAndroidStreamRelease)
- currentStreamHandle = null
+ onSkipPrevious = null
+ onSkipNext = null
+ mutableNowPlaying.value = null
+ mutableAdvancing.value = false
+ true
}
+ if (!destroy) return
+ // `mpv_terminate_destroy` joins mpv's demuxer thread, which may be
+ // parked in a block fetch over a degraded connection. This is called
+ // from Service.onDestroy — on the main thread — so waiting for it there
+ // is an ANR. `closed` is already set, so nothing new enters the handle;
+ // the lock is only to let a call that is already inside finish.
+ //
+ // Terminating the core closes the open file, and closing it releases the
+ // stream — after mpv's last read rather than during it.
+ Thread({ lifecycle.withLock { nativeDestroy(nativeHandle) } }, "pstr-mpv-teardown").start()
}
/** Serializes destruction against every JNI use of the raw Player pointer. */
@@ -166,7 +395,6 @@ class NativeMpvHost : LibmpvHost, AutoCloseable {
if (closed) null else block(nativeHandle)
}
- private external fun nativeCreate(): Long
private external fun nativeDestroy(handle: Long)
private external fun nativeAttachSurface(handle: Long, surface: Surface)
private external fun nativeDetachSurface(handle: Long)
@@ -174,17 +402,47 @@ class NativeMpvHost : LibmpvHost, AutoCloseable {
private external fun nativePause(handle: Long, paused: Boolean)
private external fun nativeSeek(handle: Long, position: Double)
private external fun nativeVolume(handle: Long, volume: Double)
+ private external fun nativeSpeed(handle: Long, speed: Double)
private external fun nativeMute(handle: Long, muted: Boolean)
private external fun nativeSelectTrack(handle: Long, audio: Boolean, track: Long)
private external fun nativeStop(handle: Long)
private external fun nativeState(handle: Long): DoubleArray
- private external fun nativeTracks(handle: Long): String
+ private external fun nativeTakeError(handle: Long): String?
+ // Nullable: `NewStringUTF` returns null when the string cannot be built,
+ // and a non-null declaration turns that into an NPE that `runCatching`
+ // swallows into a silently empty track list.
+ private external fun nativeTracks(handle: Long): String?
+ private external fun nativeChapters(handle: Long): String?
private external fun pstrAndroidStreamRelease(handle: Long)
+ private fun DoubleArray.generation(): Double = getOrElse(GENERATION) { -1.0 }
+
companion object {
+ /** Fields `nativeState` fills, in the order `pstr_mpv.cpp` writes them. */
+ private const val FIELDS = 13
+ private const val GENERATION = 6
+ private const val WIDTH = 7
+ private const val HEIGHT = 8
+ private const val END_REASON = 9
+ private const val BUFFERING = 10
+ private const val CACHE_PERCENT = 11
+ private const val SEEKING = 12
+
init {
System.loadLibrary("pstr_android")
System.loadLibrary("pstr_mpv")
}
+
+ /**
+ * A host over a live libmpv core, or null if libmpv would not start.
+ *
+ * Null rather than an exception: the service that owns this is created
+ * by the system, so a throw out of the constructor takes the process
+ * with it — and the app has a graceful "this build does not include
+ * libmpv" screen that a crash makes unreachable.
+ */
+ fun createOrNull(): NativeMpvHost? = nativeCreate().takeIf { it != 0L }?.let(::NativeMpvHost)
+
+ @JvmStatic private external fun nativeCreate(): Long
}
}
diff --git a/android/app/src/main/java/io/narl/protonstream/playback/PlaybackService.kt b/android/app/src/main/java/io/narl/protonstream/playback/PlaybackService.kt
index 9bf36ad..2083dc3 100644
--- a/android/app/src/main/java/io/narl/protonstream/playback/PlaybackService.kt
+++ b/android/app/src/main/java/io/narl/protonstream/playback/PlaybackService.kt
@@ -5,7 +5,12 @@ import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
+import android.content.BroadcastReceiver
+import android.content.Context
import android.content.Intent
+import android.content.IntentFilter
+import android.graphics.Bitmap
+import android.graphics.drawable.Icon
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
@@ -14,38 +19,99 @@ import android.media.session.MediaSession
import android.media.session.PlaybackState
import android.os.Binder
import android.os.IBinder
+import androidx.core.content.ContextCompat
import io.narl.protonstream.MainActivity
+import io.narl.protonstream.R
import io.narl.protonstream.settings.SettingsStore
+import io.narl.protonstream.ui.loadArtwork
+import io.narl.protonstream.ui.loadThumbnail
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withTimeoutOrNull
/** Owns libmpv so audio survives Activity recreation, rotation and PiP. */
class PlaybackService : Service() {
inner class PlaybackBinder : Binder() {
- val host: NativeMpvHost get() = this@PlaybackService.host
+ val host: NativeMpvHost? get() = this@PlaybackService.host
}
- private lateinit var host: NativeMpvHost
+ /**
+ * The libmpv core, or null when this build could not start one.
+ *
+ * Nullable rather than a `lateinit` that throws: the system creates this
+ * service, so a failure in its constructor takes the process down and makes
+ * the app's own "playback is unavailable" screen unreachable.
+ */
+ private var host: NativeMpvHost? = null
private lateinit var mediaSession: MediaSession
private lateinit var audioManager: AudioManager
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private var audioFocus: AudioFocusRequest? = null
- private var notifiedPaused: Boolean? = null
+ /** Whether the pause on screen is this service's doing, not the viewer's. */
+ private var pausedForFocus = false
+ /** The volume before ducking, so the restore does not read a ducked one. */
+ private var beforeDucking: Double? = null
+ /**
+ * Pulling the headphone jack must not continue on the speaker.
+ *
+ * Android broadcasts this and expects every media app to handle it; the
+ * alternative is an episode playing out loud in a quiet room.
+ */
+ private val becomingNoisy = object : BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: Intent?) {
+ if (intent?.action == AudioManager.ACTION_AUDIO_BECOMING_NOISY) host?.setPaused(true)
+ }
+ }
private var foregroundPlayback = false
private var sessionPlayback = false
+ /**
+ * What the posted notification already says.
+ *
+ * The state poll runs four times a second and almost none of those readings
+ * change anything the shade shows. Re-posting on every one of them animates
+ * the media control and costs a binder round trip per tick, so the
+ * notification is rebuilt only when its own inputs move.
+ */
+ private var notified: NotificationShape? = null
+ /** The pending teardown, held so an episode transition can call it off. */
+ private var retire: Job? = null
+ /** The episode's artwork, and the source key it was fetched for. */
+ private var artwork: Pair? = null
private val binder = PlaybackBinder()
override fun onCreate() {
super.onCreate()
createChannel()
+ ContextCompat.registerReceiver(
+ this,
+ becomingNoisy,
+ IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY),
+ ContextCompat.RECEIVER_NOT_EXPORTED,
+ )
audioManager = getSystemService(AudioManager::class.java)
- host = NativeMpvHost().apply {
+ host = NativeMpvHost.createOrNull()?.apply {
onPlaybackStarted = {
- requestAudioFocus()
+ // A refusal is rare — a call in progress — but playing over it
+ // is worse than not playing, and the viewer can simply press
+ // play again once the call ends.
+ if (!requestAudioFocus()) setPaused(true)
sessionPlayback = true
- if (SettingsStore(this@PlaybackService).backgroundAudio) {
+ cancelRetire()
+ if (!foregroundPlayback && SettingsStore(this@PlaybackService).backgroundAudio) {
// Convert the bound service into a started foreground
// service only when the viewer opted to keep audio alive.
- startService(Intent(this@PlaybackService, PlaybackService::class.java))
- startForeground(NOTIFICATION_ID, notification(false))
- foregroundPlayback = true
+ // Already-foreground is the ordinary case across an episode
+ // transition, and re-entering costs a notification rebuild.
+ foregroundPlayback = runCatching {
+ startService(Intent(this@PlaybackService, PlaybackService::class.java))
+ startForeground(NOTIFICATION_ID, notification(state.value, nowPlaying.value))
+ }.isSuccess
}
mediaSession.isActive = true
}
@@ -53,80 +119,213 @@ class PlaybackService : Service() {
onExplicitStop = { retirePlayback() }
}
mediaSession = MediaSession(this, "proton-stream").apply {
+ setSessionActivity(openActivity())
setCallback(object : MediaSession.Callback() {
- override fun onPlay() = host.setPaused(false)
- override fun onPause() = host.setPaused(true)
- override fun onSeekTo(pos: Long) = host.seek(pos / 1_000.0)
- override fun onStop() = host.stop()
+ override fun onPlay() { host?.setPaused(false) }
+ override fun onPause() { host?.setPaused(true) }
+ override fun onSeekTo(pos: Long) { host?.seek(pos / 1_000.0) }
+ override fun onStop() { host?.stop() }
+ override fun onSkipToNext() { host?.onSkipNext?.invoke() }
+ override fun onSkipToPrevious() { host?.onSkipPrevious?.invoke() }
})
}
+ // Artwork arrives after the episode does, and the shape of the transport
+ // changes with the playlist position, so the notification follows the
+ // published episode rather than being built once when playback starts.
+ val core = host ?: return
+ scope.launch {
+ core.nowPlaying.collect { playing ->
+ // Keyed by whichever source the episode has: with metadata off
+ // there is no provider poster, and Proton's own thumbnail is
+ // the only thing the shade can show.
+ artwork = playing?.let { episode ->
+ val key = episode.artworkUrl ?: episode.artworkFile?.key ?: return@let null
+ artwork?.takeIf { it.first == key }
+ ?: (
+ loadArtwork(this@PlaybackService, episode.artworkUrl)
+ ?: loadThumbnail(episode.artworkFile)
+ )?.let { key to it }
+ }
+ refreshSession(core.state.value, playing)
+ }
+ }
}
override fun onBind(intent: Intent?): IBinder = binder
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
- ACTION_PLAY -> host.setPaused(false)
- ACTION_PAUSE -> host.setPaused(true)
- ACTION_STOP -> host.stop()
+ ACTION_PLAY -> host?.setPaused(false)
+ ACTION_PAUSE -> host?.setPaused(true)
+ ACTION_PREVIOUS -> host?.onSkipPrevious?.invoke()
+ ACTION_NEXT -> host?.onSkipNext?.invoke()
+ ACTION_STOP -> host?.stop()
}
return START_NOT_STICKY
}
override fun onDestroy() {
+ scope.cancel()
+ runCatching { unregisterReceiver(becomingNoisy) }
audioFocus?.let(audioManager::abandonAudioFocusRequest)
mediaSession.release()
- host.close()
+ host?.close()
super.onDestroy()
}
- private fun requestAudioFocus() {
+ /**
+ * Take audio focus, and honour what the system says about it afterwards.
+ *
+ * The four cases are genuinely different and collapsing them is what made
+ * this feel broken: a phone call is transient and playback should come back
+ * by itself; a navigation prompt wants the volume down, not a pause; another
+ * media app taking over for good means giving the focus back rather than
+ * holding it; and a refusal means not playing at all.
+ */
+ private fun requestAudioFocus(): Boolean {
audioFocus?.let(audioManager::abandonAudioFocusRequest)
val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes(
AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MOVIE).build(),
)
+ .setWillPauseWhenDucked(false)
.setOnAudioFocusChangeListener { focus ->
when (focus) {
- AudioManager.AUDIOFOCUS_LOSS, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> host.setPaused(true)
- AudioManager.AUDIOFOCUS_GAIN -> Unit
+ AudioManager.AUDIOFOCUS_LOSS -> {
+ pausedForFocus = false
+ audioFocus?.let(audioManager::abandonAudioFocusRequest)
+ audioFocus = null
+ host?.setPaused(true)
+ }
+ AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
+ // Only resume what this paused: a viewer who paused
+ // before the phone rang expects it still paused after.
+ pausedForFocus = host?.state?.value?.paused == false
+ host?.setPaused(true)
+ }
+ AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
+ host?.let { core ->
+ val current = core.state.value.volume
+ beforeDucking = beforeDucking ?: current
+ core.setVolume(current * DUCK_FACTOR)
+ }
+ }
+ AudioManager.AUDIOFOCUS_GAIN -> {
+ beforeDucking?.let { host?.setVolume(it) }
+ beforeDucking = null
+ if (pausedForFocus) {
+ pausedForFocus = false
+ host?.setPaused(false)
+ }
+ }
}
}.build()
audioFocus = request
- audioManager.requestAudioFocus(request)
+ return audioManager.requestAudioFocus(request) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
}
private fun updateSession(state: MpvPlaybackState) {
+ refreshSession(state, host?.nowPlaying?.value)
+ if (state.ended) scheduleRetire() else cancelRetire()
+ }
+
+ /**
+ * Retire the session once playback is really over.
+ *
+ * "This file ended" is not "playback is over": autoplay walks to the next
+ * episode, and opening its stream is a network round trip. Retiring inside
+ * that gap leaves the app with no foreground state at all, so the
+ * `startForeground` on the far side is a background start — which API 31+
+ * refuses with `ForegroundServiceStartNotAllowedException`, and this app's
+ * minSdk is 31.
+ */
+ private fun scheduleRetire() {
+ if (retire?.isActive == true) return
+ retire = scope.launch {
+ // The end of file reaches the player and this service from the same
+ // poll, and the player's advance is a recomposition behind. Give it
+ // that long to declare a transition before concluding there is none.
+ delay(TRANSITION_SETTLE_MS)
+ val host = this@PlaybackService.host ?: return@launch
+ withTimeoutOrNull(TRANSITION_GRACE_MS) { host.advancing.first { !it } }
+ if (host.state.value.ended) retirePlayback()
+ }
+ }
+
+ private fun cancelRetire() {
+ retire?.cancel()
+ retire = null
+ }
+
+ private fun refreshSession(state: MpvPlaybackState, playing: NowPlaying?) {
val status = when {
state.ended -> PlaybackState.STATE_STOPPED
state.paused -> PlaybackState.STATE_PAUSED
else -> PlaybackState.STATE_PLAYING
}
+ var actions = PlaybackState.ACTION_PLAY or PlaybackState.ACTION_PAUSE or
+ PlaybackState.ACTION_PLAY_PAUSE or PlaybackState.ACTION_SEEK_TO or PlaybackState.ACTION_STOP
+ if (playing?.hasNext == true) actions = actions or PlaybackState.ACTION_SKIP_TO_NEXT
+ if (playing?.hasPrevious == true) actions = actions or PlaybackState.ACTION_SKIP_TO_PREVIOUS
mediaSession.setPlaybackState(
PlaybackState.Builder()
- .setActions(PlaybackState.ACTION_PLAY or PlaybackState.ACTION_PAUSE or PlaybackState.ACTION_SEEK_TO or PlaybackState.ACTION_STOP)
+ .setActions(actions)
.setState(status, (state.position * 1_000).toLong(), if (state.paused) 0f else 1f)
.build(),
)
- mediaSession.setMetadata(
- MediaMetadata.Builder().putLong(MediaMetadata.METADATA_KEY_DURATION, (state.duration * 1_000).toLong()).build(),
+ mediaSession.setMetadata(metadata(state, playing))
+ val shape = NotificationShape(
+ paused = state.paused,
+ media = playing?.media,
+ title = playing?.title,
+ subtitle = playing?.show,
+ hasPrevious = playing?.hasPrevious == true,
+ hasNext = playing?.hasNext == true,
+ hasArtwork = artwork != null,
)
- if (state.ended && foregroundPlayback) {
- host.releaseEndedStream()
- retirePlayback()
- return
- }
- if (foregroundPlayback && notifiedPaused != state.paused) {
- notifiedPaused = state.paused
- getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(state.paused))
+ if (foregroundPlayback && notified != shape) {
+ notified = shape
+ getSystemService(NotificationManager::class.java)
+ .notify(NOTIFICATION_ID, notification(state, playing))
}
}
+ /**
+ * What the shade, the lock screen and Android Auto read.
+ *
+ * The episode is the title and the show is the artist, which is the mapping
+ * every media surface already lays out well: one prominent line, one
+ * secondary, and the poster behind both.
+ */
+ private fun metadata(state: MpvPlaybackState, playing: NowPlaying?): MediaMetadata =
+ MediaMetadata.Builder()
+ .putString(MediaMetadata.METADATA_KEY_TITLE, playing?.title ?: "proton-stream")
+ .putString(MediaMetadata.METADATA_KEY_DISPLAY_TITLE, playing?.title ?: "proton-stream")
+ .apply {
+ playing?.show?.let {
+ putString(MediaMetadata.METADATA_KEY_ARTIST, it)
+ putString(MediaMetadata.METADATA_KEY_ALBUM, it)
+ putString(MediaMetadata.METADATA_KEY_DISPLAY_SUBTITLE, it)
+ }
+ playing?.detail?.let {
+ putString(MediaMetadata.METADATA_KEY_DISPLAY_DESCRIPTION, it)
+ }
+ artwork?.second?.let {
+ putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, it)
+ putBitmap(MediaMetadata.METADATA_KEY_ART, it)
+ putBitmap(MediaMetadata.METADATA_KEY_DISPLAY_ICON, it)
+ }
+ }
+ .putLong(MediaMetadata.METADATA_KEY_DURATION, (state.duration * 1_000).toLong())
+ .build()
+
private fun retirePlayback() {
+ cancelRetire()
if (!sessionPlayback && !foregroundPlayback) return
sessionPlayback = false
- notifiedPaused = null
+ notified = null
+ artwork = null
mediaSession.isActive = false
audioFocus?.let(audioManager::abandonAudioFocusRequest)
audioFocus = null
@@ -135,40 +334,98 @@ class PlaybackService : Service() {
stopSelf()
}
- private fun notification(paused: Boolean): Notification {
- val open = PendingIntent.getActivity(
- this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
- )
- val toggleAction = if (paused) ACTION_PLAY else ACTION_PAUSE
- val toggle = PendingIntent.getService(
- this, 1, Intent(this, PlaybackService::class.java).setAction(toggleAction), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
- )
- val stop = PendingIntent.getService(
- this, 2, Intent(this, PlaybackService::class.java).setAction(ACTION_STOP), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
- )
+ private fun openActivity(): PendingIntent = PendingIntent.getActivity(
+ this,
+ 0,
+ Intent(this, MainActivity::class.java),
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
+ )
+
+ private fun command(requestCode: Int, action: String): PendingIntent = PendingIntent.getService(
+ this,
+ requestCode,
+ Intent(this, PlaybackService::class.java).setAction(action),
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
+ )
+
+ private fun action(icon: Int, label: String, requestCode: Int, name: String): Notification.Action =
+ Notification.Action.Builder(Icon.createWithResource(this, icon), label, command(requestCode, name)).build()
+
+ private fun notification(state: MpvPlaybackState, playing: NowPlaying?): Notification {
+ val paused = state.paused
+ val actions = buildList {
+ if (playing?.hasPrevious == true) {
+ add(action(android.R.drawable.ic_media_previous, "Previous", 3, ACTION_PREVIOUS))
+ }
+ add(
+ if (paused) action(android.R.drawable.ic_media_play, "Play", 1, ACTION_PLAY)
+ else action(android.R.drawable.ic_media_pause, "Pause", 1, ACTION_PAUSE),
+ )
+ if (playing?.hasNext == true) {
+ add(action(android.R.drawable.ic_media_next, "Next", 4, ACTION_NEXT))
+ }
+ add(action(android.R.drawable.ic_menu_close_clear_cancel, "Stop", 2, ACTION_STOP))
+ }
+ val style = Notification.MediaStyle()
+ .setMediaSession(mediaSession.sessionToken)
+ // The compact view holds three: the transport as it exists for this
+ // episode, so a first episode does not show a dead Previous.
+ .setShowActionsInCompactView(*IntArray(minOf(3, actions.size)) { it })
return Notification.Builder(this, CHANNEL_ID)
- .setSmallIcon(android.R.drawable.ic_media_play)
- .setContentTitle("proton-stream")
- .setContentText(if (paused) "Playback paused" else "Playing")
- .setContentIntent(open)
+ .setSmallIcon(R.drawable.ic_notification_stream)
+ .setContentTitle(playing?.title ?: "proton-stream")
+ .setContentText(playing?.show ?: playing?.detail ?: if (paused) "Paused" else "Playing")
+ .apply {
+ playing?.takeIf { it.show != null }?.detail?.let { setSubText(it) }
+ artwork?.second?.let { setLargeIcon(it) }
+ }
+ .setColor(ACCENT)
+ .setColorized(true)
+ .setContentIntent(openActivity())
+ .setDeleteIntent(command(2, ACTION_STOP))
+ .setVisibility(Notification.VISIBILITY_PUBLIC)
.setOngoing(!paused)
- .addAction(Notification.Action.Builder(null, if (paused) "Play" else "Pause", toggle).build())
- .addAction(Notification.Action.Builder(null, "Stop", stop).build())
- .setStyle(Notification.MediaStyle().setMediaSession(mediaSession.sessionToken).setShowActionsInCompactView(0, 1))
+ .apply { actions.forEach { addAction(it) } }
+ .setStyle(style)
.build()
}
private fun createChannel() {
getSystemService(NotificationManager::class.java).createNotificationChannel(
- NotificationChannel(CHANNEL_ID, "Playback", NotificationManager.IMPORTANCE_LOW),
+ NotificationChannel(CHANNEL_ID, "Playback", NotificationManager.IMPORTANCE_LOW).apply {
+ description = "Transport controls for the episode being played."
+ setShowBadge(false)
+ },
)
}
+ /** The inputs the posted notification is built from, and nothing else. */
+ private data class NotificationShape(
+ val paused: Boolean,
+ val media: String?,
+ val title: String?,
+ val subtitle: String?,
+ val hasPrevious: Boolean,
+ val hasNext: Boolean,
+ val hasArtwork: Boolean,
+ )
+
companion object {
private const val CHANNEL_ID = "playback"
private const val NOTIFICATION_ID = 47
+ /** How long a player gets to declare that it is changing episodes. */
+ private const val TRANSITION_SETTLE_MS = 1_000L
+ /** And how long that transition may then take before it is abandoned. */
+ private const val TRANSITION_GRACE_MS = 30_000L
+ /** How far the volume drops for a navigation prompt or a notification. */
+ private const val DUCK_FACTOR = 0.3
+
+ /** Catppuccin Mocha mauve, the accent the app is themed with. */
+ private const val ACCENT = 0xFFCBA6F7.toInt()
private const val ACTION_PLAY = "io.narl.protonstream.PLAY"
private const val ACTION_PAUSE = "io.narl.protonstream.PAUSE"
+ private const val ACTION_PREVIOUS = "io.narl.protonstream.PREVIOUS"
+ private const val ACTION_NEXT = "io.narl.protonstream.NEXT"
private const val ACTION_STOP = "io.narl.protonstream.STOP"
}
}
diff --git a/android/app/src/main/java/io/narl/protonstream/playback/PlayerScreen.kt b/android/app/src/main/java/io/narl/protonstream/playback/PlayerScreen.kt
new file mode 100644
index 0000000..cfc0305
--- /dev/null
+++ b/android/app/src/main/java/io/narl/protonstream/playback/PlayerScreen.kt
@@ -0,0 +1,974 @@
+package io.narl.protonstream.playback
+
+import android.app.Activity
+import android.content.pm.ActivityInfo
+import android.view.SurfaceHolder
+import android.view.SurfaceView
+import android.view.WindowManager
+import androidx.activity.compose.BackHandler
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.safeDrawing
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.automirrored.filled.Toc
+import androidx.compose.material.icons.automirrored.filled.VolumeOff
+import androidx.compose.material.icons.automirrored.filled.VolumeUp
+import androidx.compose.material.icons.filled.Audiotrack
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Speed
+import androidx.compose.material.icons.filled.Forward30
+import androidx.compose.material.icons.filled.Fullscreen
+import androidx.compose.material.icons.filled.FullscreenExit
+import androidx.compose.material.icons.filled.Pause
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material.icons.filled.Replay10
+import androidx.compose.material.icons.filled.SkipNext
+import androidx.compose.material.icons.filled.SkipPrevious
+import androidx.compose.material.icons.filled.Subtitles
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Slider
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalWindowInfo
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.WindowInsetsControllerCompat
+import io.narl.protonstream.native.NativeRuntime
+import io.narl.protonstream.ui.thumbnailSource
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import uniffi.pstr_android.ChapterEntry
+import uniffi.pstr_android.EpisodeRecord
+import uniffi.pstr_android.TitleRecord
+import uniffi.pstr_android.TrackPreferencesRecord
+import uniffi.pstr_android.skipOffer
+import kotlin.math.roundToInt
+
+/** How long the controls stay up after a tap. */
+private const val CONTROLS_SECONDS = 4L
+
+/** How long the "up next" card counts down before it loads the next episode. */
+private const val UP_NEXT_SECONDS = 10
+
+/** Past this much of an episode it counts as watched. */
+private const val WATCHED_FRACTION = 0.9
+
+/** The loudest this app plays. Matches `pstr_core::prefs`, which stores it. */
+private const val MAX_VOLUME = 100f
+
+/**
+ * The narrowest layout the in-app volume slider is worth the room it costs.
+ *
+ * Below it — a phone held upright — the transport row is already five controls
+ * wide, and the device's own volume keys are the better answer.
+ */
+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.
+ *
+ * [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(
+ title: TitleRecord,
+ episodes: List,
+ index: Int,
+ host: LibmpvHost?,
+ inPictureInPicture: Boolean,
+ minimized: Boolean,
+ onIndexChange: (Int) -> Unit,
+ onSaveProgress: (EpisodeRecord, Double, Double, Boolean) -> Unit,
+ onMinimize: () -> Unit,
+ onClose: () -> Unit,
+) {
+ val context = LocalContext.current
+ val activity = context as? Activity
+ val titleKey = title.key
+ val episode = episodes.getOrNull(index)
+
+ // Minimised, this screen keeps the stream and the effects that own it, and
+ // draws nothing: no surface — mpv plays on without one, exactly as it does
+ // while the app is in the background — no immersive window, and no back
+ // handler, because back belongs to the page the viewer went to.
+ if (!minimized) {
+ ImmersiveWindow(activity)
+ // Back leaves the video rather than ending it. Stopping is the mini
+ // transport's ✕, which is the one place it is unambiguous.
+ BackHandler(onBack = onMinimize)
+ }
+
+ // The transport reads state only this host publishes, so the interface
+ // alone is not enough to draw a player.
+ val nativeHost = host as? NativeMpvHost
+ if (nativeHost == null || episode == null) {
+ if (minimized) return
+ Box(Modifier.fillMaxSize().background(Color.Black), contentAlignment = Alignment.Center) {
+ Text(
+ if (nativeHost == null) "Playback is unavailable: this build does not include libmpv."
+ else "This title has no episode to play.",
+ color = Color.White,
+ )
+ }
+ return
+ }
+
+ val key = mediaKey(episode)
+ val session = remember(key) { RustStreamSession(episode) }
+ var playbackError by remember(key) { mutableStateOf(null) }
+
+ LaunchedEffect(session, host) {
+ runCatching {
+ val startup = withContext(Dispatchers.IO) {
+ session.open()
+ val engine = NativeRuntime.engine()
+ // A choice made on this title wins; without one, the choice the
+ // viewer made anywhere else does. Both are the desktop client's
+ // fallback chain, and it is what makes "Japanese with English
+ // subtitles" a thing you say once rather than once per show.
+ val show = engine.titleTrackPreferences(titleKey)
+ val global = engine.playbackPrefs()
+ val watch = engine.watchState(episode.shareId, episode.linkId)
+ Startup(
+ nativeHandle = session.nativeHandle(),
+ size = session.size(),
+ // A finished episode restarts rather than resuming three
+ // seconds from its own credits.
+ startPosition = watch?.takeUnless { it.watched }?.positionSecs ?: 0.0,
+ audioLanguage = show?.audioLanguage ?: global.audioLanguage,
+ subtitleLanguage = show?.subtitleLanguage ?: global.subtitleLanguage,
+ subtitles = show?.subtitles ?: global.subtitles,
+ volume = global.volume,
+ muted = global.muted,
+ speed = global.speed,
+ )
+ }
+ nativeHost.play(
+ key, startup.nativeHandle, startup.size, startup.startPosition,
+ startup.audioLanguage, startup.subtitleLanguage, startup.subtitles,
+ )
+ // After the load, because mpv keeps none of the three across one.
+ nativeHost.setVolume(startup.volume)
+ nativeHost.setMuted(startup.muted)
+ nativeHost.setSpeed(startup.speed)
+ session.transferToPlayer()
+ }.onFailure { playbackError = it.message ?: "Playback failed" }
+ }
+
+ // Per episode: the stream Rust holds open. The surface is the screen's, not
+ // this episode's, so detaching it belongs to the effect below and not here.
+ DisposableEffect(session) { onDispose { session.close(nativeHost) } }
+ DisposableEffect(nativeHost) { onDispose { nativeHost.detachSurface() } }
+
+ val state by nativeHost.state.collectAsState()
+ val chapters by nativeHost.chapters.collectAsState()
+ val tracks by nativeHost.tracks.collectAsState()
+ // Read once: a preference the viewer changes mid-episode applies to the
+ // next one, and re-reading the store per frame would not.
+ var autoSkip by remember { mutableStateOf(false) }
+ var autoplay by remember { mutableStateOf(true) }
+ LaunchedEffect(Unit) {
+ runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().playbackPrefs() } }
+ .onSuccess { autoSkip = it.autoSkip; autoplay = it.autoplayNext }
+ }
+
+ // Resume position, written only from readings that carry this episode's
+ // name: one host serves every episode, and an unattributed reading is the
+ // previous one's clock.
+ fun save() {
+ val current = nativeHost.state.value
+ if (current.media == key && current.duration > 0.0) {
+ onSaveProgress(
+ episode,
+ current.position.coerceIn(0.0, current.duration),
+ current.duration,
+ // Watched means played to the end. An episode that was stopped,
+ // or that mpv could not play at all, also "ended" — recording
+ // either as watched retires an episode nobody saw.
+ current.endReason == EndReason.Eof ||
+ current.position >= current.duration * WATCHED_FRACTION,
+ )
+ }
+ }
+ LaunchedEffect(key) {
+ while (true) {
+ delay(5_000)
+ save()
+ }
+ }
+ DisposableEffect(key) { onDispose { save() } }
+
+ val next = episodes.getOrNull(index + 1)
+ // Both walks tell the host they are one, so the foreground service holds
+ // through the gap where nothing is loaded — see NativeMpvHost.advancing.
+ fun advance() {
+ save()
+ next?.let { nativeHost.beginTransition(); onIndexChange(index + 1) }
+ }
+ fun retreat() {
+ if (index == 0) return
+ save()
+ nativeHost.beginTransition()
+ onIndexChange(index - 1)
+ }
+
+ // 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.
+ LaunchedEffect(key, index, episodes.size, title.key) {
+ nativeHost.publish(
+ NowPlaying(
+ media = key,
+ title = episode.label,
+ show = title.canonicalName ?: title.name,
+ detail = episode.detail,
+ artworkUrl = title.posterUrl ?: title.backdropUrl,
+ artworkFile = episode.thumbnailSource,
+ hasPrevious = index > 0,
+ hasNext = next != null,
+ ),
+ )
+ }
+ // Those same surfaces walk the playlist through here, because this is where
+ // the open episode lives. Both callbacks run on the main thread.
+ DisposableEffect(nativeHost, index, episodes.size) {
+ nativeHost.onSkipNext = { advance() }
+ nativeHost.onSkipPrevious = { retreat() }
+ onDispose {
+ nativeHost.onSkipNext = null
+ nativeHost.onSkipPrevious = null
+ }
+ }
+
+ // Autoplay fires on a clean end of file only, and only for this episode's
+ // own reading — never on a failure, and never on the outgoing file's.
+ LaunchedEffect(state.endReason, state.media) {
+ if (state.endReason == EndReason.Eof && state.media == key && autoplay && next != null) advance()
+ }
+ // A file mpv could not play ends like any other. Saying so is the whole
+ // difference between a broken episode and an app that silently skipped one.
+ LaunchedEffect(state.endReason, state.media) {
+ if (state.endReason == EndReason.Failed && state.media == key) {
+ playbackError = "This episode could not be played."
+ }
+ }
+ // What mpv itself complained about, which the play call never sees: by the
+ // time a header turns out to be unreadable it has long since returned.
+ val problem by nativeHost.problem.collectAsState()
+ LaunchedEffect(problem) { problem?.let { playbackError = it } }
+
+ val offer = remember(chapters, key, (state.position * 4).roundToInt()) {
+ runCatching { skipOffer(chapters, state.position) }.getOrNull()
+ }
+ // Auto-skip is offered once per chapter: seeking back into an opening on
+ // purpose must not be undone by the setting that skipped it.
+ val skipped = remember(key) { mutableSetOf() }
+ LaunchedEffect(offer?.target) {
+ val target = offer?.target ?: return@LaunchedEffect
+ val chapter = chapters.entries.lastOrNull { state.position + 0.001 >= it.start } ?: return@LaunchedEffect
+ if (autoSkip && !state.paused && skipped.add(chapter.index)) nativeHost.seek(target)
+ }
+
+ var landscapeLocked by remember { mutableStateOf(false) }
+ var showControls by remember { mutableStateOf(true) }
+ LaunchedEffect(showControls, state.paused) {
+ if (showControls && !state.paused) {
+ delay(CONTROLS_SECONDS * 1_000)
+ 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(
+ Modifier
+ .fillMaxSize()
+ .background(Color.Black)
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null,
+ enabled = overlays,
+ onClick = { showControls = !showControls },
+ ),
+ ) {
+ AndroidView(
+ modifier = Modifier.fillMaxSize(),
+ factory = { viewContext ->
+ SurfaceView(viewContext).apply {
+ holder.addCallback(object : SurfaceHolder.Callback {
+ override fun surfaceCreated(holder: SurfaceHolder) = host.attachSurface(holder.surface)
+ override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) =
+ host.attachSurface(holder.surface)
+ override fun surfaceDestroyed(holder: SurfaceHolder) = host.detachSurface()
+ })
+ }
+ },
+ )
+
+ AnimatedVisibility(
+ visible = overlays && showControls,
+ modifier = Modifier.align(Alignment.TopStart),
+ enter = fadeIn(),
+ exit = fadeOut(),
+ ) {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .background(Color.Black.copy(alpha = 0.45f))
+ .windowInsetsPadding(WindowInsets.safeDrawing)
+ .padding(horizontal = 8.dp, vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ IconButton(onClick = onMinimize) {
+ Icon(
+ Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = "Leave the video",
+ tint = Color.White,
+ )
+ }
+ // The episode leads and the show follows it: which episode is
+ // the thing a viewer who has just woken the controls is
+ // checking, and the show is what they already know.
+ Column(Modifier.weight(1f)) {
+ Text(
+ episode.label,
+ color = Color.White,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ style = MaterialTheme.typography.titleMedium,
+ )
+ Text(
+ listOfNotNull(title.canonicalName ?: title.name, episode.detail.takeIf(String::isNotBlank))
+ .joinToString(" • "),
+ color = Color.White.copy(alpha = 0.7f),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ // Two ways out, because they mean different things: back leaves
+ // the video and keeps the episode playing, this ends it.
+ IconButton(onClick = onClose) {
+ Icon(Icons.Default.Close, contentDescription = "Stop playback", tint = Color.White)
+ }
+ remaining(state)?.let {
+ Text(
+ it,
+ color = Color.White.copy(alpha = 0.7f),
+ style = MaterialTheme.typography.labelMedium,
+ modifier = Modifier.padding(start = 12.dp, end = 4.dp),
+ )
+ }
+ }
+ }
+
+ AnimatedVisibility(
+ visible = overlays && showControls,
+ modifier = Modifier.align(Alignment.BottomCenter),
+ enter = fadeIn(),
+ exit = fadeOut(),
+ ) {
+ PlayerControls(
+ titleKey = titleKey,
+ host = nativeHost,
+ state = state,
+ chapters = chapters.entries,
+ tracks = tracks,
+ hasPrevious = index > 0,
+ hasNext = next != null,
+ onPrevious = { retreat() },
+ onNext = { advance() },
+ onRotate = { landscapeLocked = activity?.toggleLandscape() ?: false },
+ landscapeLocked = landscapeLocked,
+ )
+ }
+
+ // 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 ->
+ FilledTonalButton(
+ onClick = { nativeHost.seek(skip.target) },
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .windowInsetsPadding(WindowInsets.safeDrawing)
+ .padding(end = 16.dp, bottom = if (showControls) 132.dp else 24.dp),
+ ) { Text(skip.label) }
+ }
+
+ UpNextCard(
+ next = next,
+ visible = overlays && autoplay && !state.paused &&
+ chapters.creditsStart?.let { state.position >= it } == true,
+ onPlayNow = { advance() },
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .windowInsetsPadding(WindowInsets.safeDrawing)
+ .padding(end = 16.dp, bottom = if (showControls) 132.dp else 24.dp),
+ )
+
+ // Only while nothing else explains the still picture: an error is the
+ // more useful thing to say, and a spinner over it reads as "still
+ // trying".
+ if (state.stalled && playbackError == null) {
+ Column(
+ Modifier
+ .align(Alignment.Center)
+ .background(Color.Black.copy(alpha = 0.55f), RoundedCornerShape(12.dp))
+ .padding(20.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ CircularProgressIndicator(color = Color.White, modifier = Modifier.size(36.dp))
+ if (state.buffering) {
+ Text(
+ state.cachePercent.takeIf { it > 0.0 }
+ ?.let { "Buffering… ${it.roundToInt()}%" } ?: "Buffering…",
+ color = Color.White,
+ style = MaterialTheme.typography.labelMedium,
+ modifier = Modifier.padding(top = 12.dp),
+ )
+ }
+ }
+ }
+
+ playbackError?.let { message ->
+ Column(
+ Modifier
+ .align(Alignment.Center)
+ .background(Color.Black.copy(alpha = 0.85f), RoundedCornerShape(12.dp))
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(message, color = Color.White)
+ // Dismissible, because the message is not always fatal — a
+ // subtitle track that failed to load leaves an episode that
+ // plays perfectly well behind an error that used to be permanent.
+ TextButton(onClick = { playbackError = null; nativeHost.clearProblem() }) {
+ Text("Dismiss", color = Color.White)
+ }
+ }
+ }
+ }
+}
+
+private data class Startup(
+ val nativeHandle: ULong,
+ val size: ULong,
+ val startPosition: Double,
+ val audioLanguage: String?,
+ val subtitleLanguage: String?,
+ val subtitles: Boolean,
+ val volume: Double,
+ val muted: Boolean,
+ val speed: Double,
+)
+
+/**
+ * Immersive for as long as this screen is up, and only that long.
+ *
+ * Hiding the bars is not enough on its own: without `setDecorFitsSystemWindows`
+ * and a cutout mode the picture stops at the notch and at the gesture bar, which
+ * is the "not really fullscreen" everyone notices.
+ */
+@Composable
+private fun ImmersiveWindow(activity: Activity?) {
+ DisposableEffect(activity) {
+ val window = activity?.window
+ if (window == null) return@DisposableEffect onDispose {}
+ val controller = WindowInsetsControllerCompat(window, window.decorView)
+ val previousCutout = window.attributes.layoutInDisplayCutoutMode
+ WindowCompat.setDecorFitsSystemWindows(window, false)
+ window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+ window.attributes = window.attributes.also {
+ it.layoutInDisplayCutoutMode =
+ WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
+ }
+ controller.hide(WindowInsetsCompat.Type.systemBars())
+ controller.systemBarsBehavior =
+ WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
+ onDispose {
+ controller.show(WindowInsetsCompat.Type.systemBars())
+ window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+ window.attributes = window.attributes.also { it.layoutInDisplayCutoutMode = previousCutout }
+ activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
+ }
+ }
+}
+
+/** Lock to landscape, or hand rotation back to the sensor. Reports which. */
+private fun Activity.toggleLandscape(): Boolean {
+ val lock = requestedOrientation != ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
+ requestedOrientation = if (lock) ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
+ else ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
+ return lock
+}
+
+/**
+ * Everything the transport offers, in two rows.
+ *
+ * A phone in landscape has one thumb's worth of room, so the second row is
+ * icons: the transport cluster on the left, where a thumb already is, and the
+ * pickers on the right. Only the seek bar gets a full row of its own.
+ */
+@Composable
+private fun PlayerControls(
+ titleKey: String,
+ host: NativeMpvHost,
+ state: MpvPlaybackState,
+ chapters: List,
+ tracks: List,
+ hasPrevious: Boolean,
+ hasNext: Boolean,
+ onPrevious: () -> Unit,
+ onNext: () -> Unit,
+ onRotate: () -> Unit,
+ landscapeLocked: Boolean,
+) {
+ val scope = rememberCoroutineScope()
+ var choosing by remember { mutableStateOf(null) }
+ var showChapters by remember { mutableStateOf(false) }
+ var showSpeeds by remember { mutableStateOf(false) }
+ // What the store says, so the dialog opens on the rate that is playing.
+ var speed by remember { mutableStateOf(1.0) }
+ LaunchedEffect(Unit) {
+ runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().playbackPrefs() } }
+ .onSuccess { speed = it.speed }
+ }
+ val duration = state.duration.toFloat().coerceAtLeast(1f)
+ val density = LocalDensity.current
+ val roomForVolume = with(density) {
+ LocalWindowInfo.current.containerSize.width.toDp() >= VOLUME_SLIDER_MIN_WIDTH
+ }
+
+ Column(
+ Modifier
+ .fillMaxWidth()
+ .background(Color.Black.copy(alpha = 0.68f))
+ .windowInsetsPadding(WindowInsets.safeDrawing)
+ .padding(horizontal = 12.dp, vertical = 4.dp),
+ ) {
+ // Where the thumb is while a finger is on it. A seek per touch-move
+ // aborts every outstanding prefetch, so a one-second drag used to issue
+ // dozens of seeks and throw away every in-flight block — and the thumb
+ // snapped backwards under the finger, because it was drawn from a
+ // position that only updates four times a second.
+ var scrubbing by remember { mutableStateOf(null) }
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ formatTime(scrubbing?.toDouble() ?: state.position),
+ color = Color.White,
+ style = MaterialTheme.typography.labelMedium,
+ )
+ Box(Modifier.weight(1f)) {
+ Slider(
+ value = scrubbing ?: state.position.toFloat().coerceIn(0f, duration),
+ onValueChange = { scrubbing = it },
+ onValueChangeFinished = {
+ scrubbing?.let { host.seek(it.toDouble()) }
+ scrubbing = null
+ },
+ valueRange = 0f..duration,
+ )
+ ChapterMarks(chapters, state.duration, Modifier.matchParentSize())
+ }
+ Text(formatTime(state.duration), color = Color.White, style = MaterialTheme.typography.labelMedium)
+ }
+ Row(
+ Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ CompactIcon(Icons.Default.SkipPrevious, "Previous episode", enabled = hasPrevious, onClick = onPrevious)
+ CompactIcon(Icons.Default.Replay10, "Back ten seconds") { host.seek(state.position - 10) }
+ IconButton(onClick = { host.setPaused(!state.paused) }) {
+ Icon(
+ if (state.paused) Icons.Default.PlayArrow else Icons.Default.Pause,
+ contentDescription = if (state.paused) "Play" else "Pause",
+ tint = Color.White,
+ modifier = Modifier.size(32.dp),
+ )
+ }
+ CompactIcon(Icons.Default.Forward30, "Forward thirty seconds") { host.seek(state.position + 30) }
+ CompactIcon(Icons.Default.SkipNext, "Next episode", enabled = hasNext, onClick = onNext)
+ }
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ if (chapters.size > 1) {
+ CompactIcon(Icons.AutoMirrored.Filled.Toc, "Chapters") { showChapters = true }
+ }
+ CompactIcon(Icons.Default.Speed, "Playback speed") { showSpeeds = true }
+ CompactIcon(Icons.Default.Audiotrack, "Audio track") { choosing = "audio" }
+ CompactIcon(Icons.Default.Subtitles, "Subtitles") { choosing = "sub" }
+ CompactIcon(
+ if (state.muted) Icons.AutoMirrored.Filled.VolumeOff else Icons.AutoMirrored.Filled.VolumeUp,
+ "Mute",
+ ) {
+ host.setMuted(!state.muted)
+ scope.launch(Dispatchers.IO) { rememberVolume(muted = !state.muted) }
+ }
+ // The app's own volume, which is not the device's: a file
+ // mastered quiet is turned up here and stays up for the next
+ // one. Only where there is room for it — a phone in portrait
+ // has the system volume keys and no width to spare.
+ if (roomForVolume) {
+ var dragging by remember { mutableStateOf(null) }
+ Slider(
+ value = dragging ?: state.volume.toFloat().coerceIn(0f, MAX_VOLUME),
+ onValueChange = { dragging = it; host.setVolume(it.toDouble()) },
+ onValueChangeFinished = {
+ val chosen = dragging?.toDouble()
+ dragging = null
+ chosen?.let { scope.launch(Dispatchers.IO) { rememberVolume(volume = it) } }
+ },
+ valueRange = 0f..MAX_VOLUME,
+ modifier = Modifier.width(96.dp),
+ )
+ }
+ CompactIcon(
+ if (landscapeLocked) Icons.Default.FullscreenExit else Icons.Default.Fullscreen,
+ if (landscapeLocked) "Unlock rotation" else "Lock to landscape",
+ onClick = onRotate,
+ )
+ }
+ }
+ }
+
+ if (showSpeeds) {
+ SpeedChooser(
+ current = speed,
+ onDismiss = { showSpeeds = false },
+ onSelect = { chosen ->
+ speed = chosen
+ host.setSpeed(chosen)
+ showSpeeds = false
+ scope.launch(Dispatchers.IO) { rememberSpeed(chosen) }
+ },
+ )
+ }
+ if (showChapters) {
+ ChapterChooser(
+ chapters = chapters,
+ position = state.position,
+ onDismiss = { showChapters = false },
+ onSelect = { host.seek(it.start); showChapters = false },
+ )
+ }
+ choosing?.let { type ->
+ TrackChooser(
+ type = type,
+ tracks = tracks.filter { it.type == type },
+ onDismiss = { choosing = null },
+ onSelect = { selected ->
+ host.selectTrack(selected)
+ choosing = null
+ scope.launch(Dispatchers.IO) {
+ val engine = NativeRuntime.engine()
+ // No per-title choice yet: start from what plays everywhere
+ // else, so choosing an audio track does not silently drop
+ // the subtitle preference this episode started with.
+ val global = engine.playbackPrefs()
+ val old = engine.titleTrackPreferences(titleKey) ?: TrackPreferencesRecord(
+ audioLanguage = global.audioLanguage,
+ subtitleLanguage = global.subtitleLanguage,
+ subtitles = global.subtitles,
+ )
+ engine.setTitleTrackPreferences(
+ titleKey,
+ if (type == "audio") {
+ old.copy(audioLanguage = selected?.language?.takeIf(String::isNotBlank))
+ } else {
+ // Turning subtitles off keeps the language it was
+ // off *from*, so turning them back on does not have
+ // to be told the language again — as on desktop.
+ old.copy(
+ subtitleLanguage = selected?.language?.takeIf(String::isNotBlank)
+ ?: old.subtitleLanguage,
+ subtitles = selected != null,
+ )
+ },
+ )
+ }
+ },
+ )
+ }
+}
+
+/**
+ * Persist a volume or a mute, leaving whichever was not given as it was.
+ *
+ * Written when the thumb is released rather than while it moves: a drag emits
+ * dozens of values a second and every one of them would be a file rewrite. A
+ * failure is dropped — the volume is already applied, and the cost of not
+ * storing it is that the next launch starts where the last one was stored.
+ */
+private suspend fun rememberVolume(volume: Double? = null, muted: Boolean? = null) {
+ runCatching {
+ val engine = NativeRuntime.engine()
+ val prefs = engine.playbackPrefs()
+ engine.setPlaybackPrefs(prefs.copy(volume = volume ?: prefs.volume, muted = muted ?: prefs.muted))
+ }
+}
+
+/** Persist a playback rate, so the next episode starts at it. */
+private suspend fun rememberSpeed(speed: Double) {
+ runCatching {
+ val engine = NativeRuntime.engine()
+ engine.setPlaybackPrefs(engine.playbackPrefs().copy(speed = speed))
+ }
+}
+
+/**
+ * The rates worth offering.
+ *
+ * A short list rather than a slider: the useful rates are a handful of ratios,
+ * and a slider on a phone lands on 1.07 as often as on 1.0.
+ */
+private val SPEEDS = listOf(0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0)
+
+@Composable
+private fun SpeedChooser(current: Double, onDismiss: () -> Unit, onSelect: (Double) -> Unit) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text("Playback speed") },
+ text = {
+ Column(Modifier.fillMaxWidth()) {
+ SPEEDS.forEach { rate ->
+ OutlinedButton(
+ onClick = { onSelect(rate) },
+ modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
+ ) {
+ Text(
+ (if (kotlin.math.abs(rate - current) < 0.001) "✓ " else "") +
+ if (rate == 1.0) "Normal" else "${rate}×",
+ )
+ }
+ }
+ }
+ },
+ confirmButton = { Button(onClick = onDismiss) { Text("Close") } },
+ )
+}
+
+/** Where each chapter begins, ticked onto the seek bar. */
+@Composable
+private fun ChapterMarks(chapters: List, duration: Double, modifier: Modifier) {
+ if (chapters.size < 2 || duration <= 0.0) return
+ Canvas(modifier.padding(horizontal = 10.dp)) {
+ val width = 2.dp.toPx()
+ val height = 8.dp.toPx()
+ chapters.drop(1).forEach { chapter ->
+ val fraction = (chapter.start / duration).coerceIn(0.0, 1.0).toFloat()
+ drawRect(
+ color = Color.White.copy(alpha = 0.75f),
+ topLeft = Offset(fraction * size.width - width / 2, (size.height - height) / 2),
+ size = Size(width, height),
+ )
+ }
+ }
+}
+
+@Composable
+private fun ChapterChooser(
+ chapters: List,
+ position: Double,
+ onDismiss: () -> Unit,
+ onSelect: (ChapterEntry) -> Unit,
+) {
+ val current = chapters.indexOfLast { position + 0.001 >= it.start }
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text("Chapters") },
+ text = {
+ LazyColumn(Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
+ items(chapters, key = ChapterEntry::index) { chapter ->
+ OutlinedButton(
+ onClick = { onSelect(chapter) },
+ modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
+ ) {
+ Text(
+ (if (chapters.getOrNull(current) === chapter) "✓ " else "") +
+ "${formatTime(chapter.start)} ${chapter.label}",
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+ }
+ },
+ confirmButton = { Button(onClick = onDismiss) { Text("Close") } },
+ )
+}
+
+/**
+ * The countdown into the next episode, shown once the credits run starts.
+ *
+ * "Watch till the end" holds for the rest of the file: someone who wants the
+ * post-credits scene should have to say so once, not once every ten seconds.
+ */
+@Composable
+private fun UpNextCard(
+ next: EpisodeRecord?,
+ visible: Boolean,
+ onPlayNow: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ var held by remember(next?.linkId) { mutableStateOf(false) }
+ var remaining by remember(next?.linkId) { mutableStateOf(UP_NEXT_SECONDS) }
+ val upNext = next
+ val showing = visible && upNext != null && !held
+ LaunchedEffect(showing) {
+ if (!showing) return@LaunchedEffect
+ remaining = UP_NEXT_SECONDS
+ while (remaining > 0) {
+ delay(1_000)
+ remaining -= 1
+ }
+ onPlayNow()
+ }
+ // `showing` already implies a non-null episode, and the compiler knows it.
+ if (!showing) return
+ Column(
+ modifier
+ .background(Color.Black.copy(alpha = 0.85f), RoundedCornerShape(12.dp))
+ .padding(12.dp),
+ ) {
+ Text("Up next in ${remaining}s", color = Color.White, style = MaterialTheme.typography.labelMedium)
+ Text(
+ upNext.label,
+ color = Color.White,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ style = MaterialTheme.typography.titleSmall,
+ )
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 8.dp)) {
+ Button(onClick = onPlayNow) { Text("Play now") }
+ TextButton(onClick = { held = true }) { Text("Watch till the end", color = Color.White) }
+ }
+ }
+}
+
+@Composable
+private fun TrackChooser(
+ type: String,
+ tracks: List,
+ onDismiss: () -> Unit,
+ onSelect: (MpvTrack?) -> Unit,
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(if (type == "audio") "Audio track" else "Subtitle track") },
+ text = {
+ LazyColumn(Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
+ if (type == "sub") {
+ item {
+ OutlinedButton(onClick = { onSelect(null) }, Modifier.fillMaxWidth()) {
+ Text(if (tracks.none(MpvTrack::selected)) "✓ Off" else "Off")
+ }
+ }
+ }
+ items(tracks, key = MpvTrack::id) { track ->
+ OutlinedButton(onClick = { onSelect(track) }, Modifier.fillMaxWidth()) {
+ Text(if (track.selected) "✓ ${track.label}" else track.label)
+ }
+ }
+ }
+ },
+ confirmButton = { Button(onClick = onDismiss) { Text("Close") } },
+ )
+}
+
+@Composable
+private fun CompactIcon(
+ icon: androidx.compose.ui.graphics.vector.ImageVector,
+ description: String,
+ enabled: Boolean = true,
+ onClick: () -> Unit,
+) {
+ IconButton(onClick = onClick, enabled = enabled, modifier = Modifier.size(40.dp)) {
+ Icon(
+ icon,
+ contentDescription = description,
+ tint = if (enabled) Color.White else Color.White.copy(alpha = 0.35f),
+ modifier = Modifier.size(22.dp),
+ )
+ }
+}
+
+private fun ((Double) -> Unit).asFloat(): (Float) -> Unit = { this(it.toDouble()) }
+
+/** How much of the episode is left, or nothing before the duration is known. */
+private fun remaining(state: MpvPlaybackState): String? = (state.duration - state.position)
+ .takeIf { state.duration > 0.0 && it >= 1.0 }
+ ?.let { "${formatTime(it)} left" }
+
+private fun formatTime(seconds: Double): String {
+ val total = seconds.coerceAtLeast(0.0).roundToInt()
+ val hours = total / 3_600
+ return if (hours > 0) "%d:%02d:%02d".format(hours, (total % 3_600) / 60, total % 60)
+ else "%d:%02d".format(total / 60, total % 60)
+}
diff --git a/android/app/src/main/java/io/narl/protonstream/playback/StreamSession.kt b/android/app/src/main/java/io/narl/protonstream/playback/StreamSession.kt
new file mode 100644
index 0000000..5156ddc
--- /dev/null
+++ b/android/app/src/main/java/io/narl/protonstream/playback/StreamSession.kt
@@ -0,0 +1,102 @@
+package io.narl.protonstream.playback
+
+import android.view.Surface
+import io.narl.protonstream.native.NativeRuntime
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import uniffi.pstr_android.AndroidEngine
+import uniffi.pstr_android.AndroidStream
+import uniffi.pstr_android.EpisodeRecord
+
+/** Exact seekable stream contract a bundled libmpv JNI adapter must consume. */
+class RustStreamSession(
+ private val episode: EpisodeRecord,
+) {
+ private var engine: AndroidEngine? = null
+ private var stream: AndroidStream? = null
+ private var handle: ULong? = null
+ private var streamSize: ULong? = null
+
+ suspend fun open(): RustStreamSession = withContext(Dispatchers.IO) {
+ val openedEngine = NativeRuntime.engine()
+ engine = openedEngine
+ stream = openedEngine.openStream(episode.shareId, episode.volumeId, episode.linkId).also {
+ streamSize = it.size()
+ handle = it.nativeHandle()
+ }
+ this@RustStreamSession
+ }
+
+ fun nativeHandle(): ULong = checkNotNull(handle) { "stream is not published" }
+
+ fun size(): ULong = checkNotNull(streamSize) { "stream is not open" }
+
+ fun releaseNative(host: LibmpvHost) {
+ handle?.let(host::releaseNativeStream)
+ handle = null
+ streamSize = null
+ stream = null
+ }
+
+ /** The native player now owns the published token until stop/end/close. */
+ fun transferToPlayer() {
+ handle = null
+ streamSize = null
+ stream = null
+ }
+
+ /**
+ * Give the episode's stream back to Rust, from a scope of this session's own.
+ *
+ * Deliberately not the caller's: the one moment this has to run is when the
+ * player leaves the composition, which is the same moment a
+ * `rememberCoroutineScope` is cancelled — so a release launched there is
+ * cancelled before it starts, and the stream is held for the life of the
+ * process. Once per episode played, that is every episode.
+ */
+ fun close(host: LibmpvHost) {
+ releaseNative(host)
+ sessions.launch {
+ runCatching { engine?.releaseStream(episode.shareId, episode.volumeId, episode.linkId) }
+ engine = null
+ }
+ }
+}
+
+/**
+ * Where stream teardown runs: process-lifetime, like the engine it releases to,
+ * and never cancelled by a screen going away.
+ */
+private val sessions = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+
+/** Implement only in the variant that bundles a pinned libmpv native artifact. */
+interface LibmpvHost {
+ fun attachSurface(surface: Surface)
+ fun detachSurface()
+
+ /**
+ * Consumes the opaque handle through pstr_android_stream_{read,size}.
+ *
+ * [media] names the episode being loaded. One host serves every episode in
+ * turn, so its readings have to say which one they describe — see
+ * [MpvPlaybackState.media].
+ */
+ suspend fun play(
+ media: String,
+ nativeHandle: ULong,
+ size: ULong,
+ startPosition: Double = 0.0,
+ audioLanguage: String? = null,
+ subtitleLanguage: String? = null,
+ subtitles: Boolean = true,
+ )
+
+ /** Calls pstr_android_stream_release exactly once for a published handle. */
+ fun releaseNativeStream(nativeHandle: ULong)
+}
+
+/** How an episode is named in watch state, work names and player readings. */
+fun mediaKey(episode: EpisodeRecord) = "${episode.shareId}:${episode.linkId}"
diff --git a/android/app/src/main/java/io/narl/protonstream/settings/SettingsStore.kt b/android/app/src/main/java/io/narl/protonstream/settings/SettingsStore.kt
index 8c41cc1..d03aa46 100644
--- a/android/app/src/main/java/io/narl/protonstream/settings/SettingsStore.kt
+++ b/android/app/src/main/java/io/narl/protonstream/settings/SettingsStore.kt
@@ -2,6 +2,13 @@ package io.narl.protonstream.settings
import android.content.Context
+/**
+ * The settings that are Android's alone.
+ *
+ * Playback preferences — volume, languages, autoplay, auto-skip — deliberately
+ * do *not* live here: they are `pstr_core::prefs`, reached over the bridge, so
+ * there is one definition of them rather than one per front end.
+ */
class SettingsStore(context: Context) {
private val preferences = context.getSharedPreferences(NAME, Context.MODE_PRIVATE)
diff --git a/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt b/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt
index 475232a..e17da8a 100644
--- a/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt
+++ b/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt
@@ -28,8 +28,11 @@ import io.narl.protonstream.native.NativeRuntime
import uniffi.pstr_android.ShareRecord
import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.OfflineRecord
+import uniffi.pstr_android.MatchSummary
import uniffi.pstr_android.MetadataProvider
import uniffi.pstr_android.MetadataSettingsRecord
+import uniffi.pstr_android.StorageUsageRecord
+import uniffi.pstr_android.EpisodeRecord
data class AppUiState(
val loading: Boolean = true,
@@ -44,6 +47,7 @@ data class AppUiState(
language = "en",
ready = true,
),
+ val storage: StorageUsageRecord = StorageUsageRecord(0uL, 0uL, 0uL, 0uL),
val message: String? = null,
)
@@ -55,6 +59,11 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
val state: StateFlow = mutableState.asStateFlow()
init {
+ if (!NativeRuntime.tlsReady) {
+ mutableState.update {
+ it.copy(message = "Certificate verification is unavailable; Proton requests will fail")
+ }
+ }
reload()
viewModelScope.launch {
searchQuery.debounce(300).distinctUntilChanged().collect { reloadLibrary(it) }
@@ -83,6 +92,43 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
}
}
+ /**
+ * Recrawl one share rather than the whole library.
+ *
+ * A viewer who has just added files to one link should not have to pay for
+ * a walk of every other one — and a share whose link has expired makes a
+ * whole-library refresh fail, which used to leave no way to refresh the
+ * shares that still work.
+ */
+ fun refreshShare(id: String) {
+ viewModelScope.launch {
+ mutableState.update { it.copy(refreshing = true, message = null) }
+ runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(id) } }
+ .onFailure { error -> mutableState.update { it.copy(message = error.message) } }
+ mutableState.update { it.copy(refreshing = false) }
+ reload()
+ }
+ }
+
+ /**
+ * Run enrichment again.
+ *
+ * With [force] every title is looked up afresh, including ones that already
+ * matched — the way out of a library where a provider's early answers were
+ * wrong, or where a match was made against a provider that has since been
+ * changed.
+ */
+ fun matchTitles(force: Boolean) {
+ viewModelScope.launch {
+ mutableState.update { it.copy(refreshing = true, message = null) }
+ runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().matchTitles(force) } }
+ .onSuccess { summary -> mutableState.update { it.copy(message = describe(summary)) } }
+ .onFailure { error -> mutableState.update { it.copy(message = error.message) } }
+ mutableState.update { it.copy(refreshing = false) }
+ reload()
+ }
+ }
+
fun addShare(name: String, url: String, password: String?) {
viewModelScope.launch {
runCatching {
@@ -96,6 +142,28 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
}
}
+ /**
+ * Re-supply the link behind a share whose stored secret cannot be read.
+ *
+ * The Keystore key is invalidated by a lockscreen change or a device
+ * restore, and after that every open of every share fails — including the
+ * read inside "remove this share", so the obvious way out is closed too.
+ * Re-entering the link rewrites the secret and leaves the catalog and the
+ * offline files alone.
+ */
+ fun repairShare(id: String, url: String, password: String?) {
+ viewModelScope.launch {
+ runCatching {
+ withContext(Dispatchers.IO) {
+ val engine = NativeRuntime.engine()
+ engine.repairShare(id, url, password?.takeIf(String::isNotBlank))
+ engine.crawl(id)
+ }
+ }.onFailure(::reportError)
+ reload()
+ }
+ }
+
fun removeShare(id: String) {
viewModelScope.launch {
runCatching {
@@ -132,8 +200,10 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
engine.setMetadataApiKey(provider, apiKey)
}
engine.setMetadataSettings(MetadataSettingsRecord(enabled, provider, language, true))
- if (enabled) engine.matchTitles(false)
+ if (enabled) engine.matchTitles(false) else null
}
+ }.onSuccess { summary ->
+ summary?.let { mutableState.update { state -> state.copy(message = describe(it)) } }
}.onFailure(::reportError)
reload()
}
@@ -141,6 +211,23 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
fun reloadAfterMetadataChange() = reload()
+ /**
+ * What an enrichment pass did, in one line.
+ *
+ * Failures are reported even when most titles matched: a run that quietly
+ * dropped a third of the library is the case this counting exists for.
+ */
+ private fun describe(summary: MatchSummary): String {
+ if (summary.matched == 0u && summary.unmatched == 0u && summary.failed == 0u) {
+ return "Everything is already matched"
+ }
+ val parts = mutableListOf("${summary.matched} matched")
+ if (summary.unmatched > 0u) parts += "${summary.unmatched} not found"
+ if (summary.episodes > 0u) parts += "${summary.episodes} episodes named"
+ if (summary.failed > 0u) parts += "${summary.failed} failed"
+ return parts.joinToString(", ")
+ }
+
fun removeOffline(file: OfflineRecord) {
viewModelScope.launch {
runCatching {
@@ -170,16 +257,96 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
}
}
+ /**
+ * Persist where an episode was left.
+ *
+ * The player calls this while it is still up and once more as it leaves, so
+ * it runs on the ViewModel's scope rather than the screen's — a save issued
+ * on the way out must not be cancelled by the screen going away.
+ */
+ fun saveProgress(episode: EpisodeRecord, position: Double, duration: Double, watched: Boolean) {
+ viewModelScope.launch {
+ runCatching {
+ withContext(Dispatchers.IO) {
+ NativeRuntime.engine().saveWatchState(
+ episode.shareId,
+ episode.linkId,
+ position.coerceIn(0.0, duration),
+ duration,
+ watched,
+ )
+ }
+ }.onFailure(::reportError)
+ }
+ }
+
+ /**
+ * Mark an episode seen, or unseen, without playing it.
+ *
+ * Unwatching rewinds, matching the desktop client: an episode marked unseen
+ * that kept its position would come straight back as "resume at 19:04",
+ * which is not what the viewer asked for. Marking one seen puts the position
+ * at its duration where one is known, so the progress bar agrees with the
+ * tick.
+ */
+ fun setWatched(episode: EpisodeRecord, watched: Boolean) {
+ viewModelScope.launch {
+ runCatching {
+ withContext(Dispatchers.IO) {
+ val engine = NativeRuntime.engine()
+ val duration = engine.watchState(episode.shareId, episode.linkId)?.durationSecs
+ engine.saveWatchState(
+ episode.shareId,
+ episode.linkId,
+ if (watched) duration ?: 0.0 else 0.0,
+ duration,
+ watched,
+ )
+ }
+ }.onFailure(::reportError)
+ reload()
+ }
+ }
+
+ fun removeAllOffline() {
+ viewModelScope.launch {
+ runCatching {
+ withContext(Dispatchers.IO) {
+ workManager.cancelAllWorkByTag(DownloadCoordinator.TAG).result.get()
+ NativeRuntime.engine().removeAllOffline()
+ DownloadStateStore(appContext).clear()
+ }
+ }.onFailure(::reportError)
+ reload()
+ }
+ }
+
+ fun clearBlockCache() {
+ viewModelScope.launch {
+ runCatching {
+ withContext(Dispatchers.IO) { NativeRuntime.engine().clearBlockCache() }
+ }.onSuccess { reclaimed ->
+ mutableState.update { it.copy(message = "Reclaimed ${formatBytes(reclaimed)} of cache") }
+ }.onFailure(::reportError)
+ reload()
+ }
+ }
+
private fun reload() {
viewModelScope.launch {
runCatching {
withContext(Dispatchers.IO) {
val engine = NativeRuntime.engine()
+ // Here rather than inside `library()`, which is read-only
+ // and cached: a full reload is the one place that has
+ // already paid for a walk of the offline files.
+ engine.pruneOfflineFiles()
Reloaded(
engine.shares(),
engine.library(mutableState.value.query.takeIf(String::isNotBlank)),
engine.offlineFiles(),
engine.metadataSettings(),
+ engine.storageUsage(),
)
}
}.onSuccess { result ->
@@ -189,6 +356,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
titles = result.titles,
offline = result.offline,
metadataSettings = result.metadataSettings,
+ storage = result.storage,
) }
}
.onFailure { error ->
@@ -215,6 +383,7 @@ private data class Reloaded(
val titles: List,
val offline: List,
val metadataSettings: MetadataSettingsRecord,
+ val storage: StorageUsageRecord,
)
private fun WorkManager.workInfosByTagFlow(tag: String) = callbackFlow {
diff --git a/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt b/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt
index a5dcaa3..857e5f2 100644
--- a/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt
+++ b/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt
@@ -1,10 +1,11 @@
package io.narl.protonstream.ui
-import android.content.res.Configuration
+import android.content.Intent
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
-import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
+import androidx.compose.foundation.border
+import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -20,13 +21,14 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
-import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
@@ -48,6 +50,8 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material.icons.outlined.CheckCircle
import androidx.compose.material.icons.filled.VideoLibrary
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Download
@@ -55,26 +59,30 @@ import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Pause
+import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
@@ -84,6 +92,7 @@ import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.SecureFlagPolicy
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
+import androidx.core.net.toUri
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.work.WorkManager
import io.narl.protonstream.settings.SettingsStore
@@ -94,14 +103,36 @@ import io.narl.protonstream.download.DownloadCoordinator
import io.narl.protonstream.download.DownloadStateStore
import io.narl.protonstream.download.RetainedDownload
import io.narl.protonstream.native.NativeRuntime
+import io.narl.protonstream.ui.theme.AppearanceState
import uniffi.pstr_android.ShareRecord
import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.TrackPreferencesRecord
import uniffi.pstr_android.EpisodeRecord
+import uniffi.pstr_android.SeasonRecord
import uniffi.pstr_android.MatchRecord
import uniffi.pstr_android.MetadataProvider
+import uniffi.pstr_android.FlavorChoice
+import uniffi.pstr_android.AppearanceRecord
+import uniffi.pstr_android.AccentChoice
+import uniffi.pstr_android.PlaybackPrefsRecord
+import io.narl.protonstream.playback.LibmpvHost
import io.narl.protonstream.playback.NativeMpvHost
-import io.narl.protonstream.playback.LibmpvPlayerSurface
+import io.narl.protonstream.playback.PlayerScreen
+
+/**
+ * How many shows the continue-watching shelf holds.
+ *
+ * A shelf is a shortcut, not a second library: past about this many it is
+ * quicker to search for the thing than to scroll the shelf looking for it.
+ */
+private const val CONTINUE_WATCHING_MAX = 12
+
+/** What the player was asked to open: a title's episodes, and which one. */
+private data class PlayRequest(
+ val title: TitleRecord,
+ val episodes: List,
+ val index: Int,
+)
private enum class Destination(val label: String, val icon: ImageVector) {
Library("Library", Icons.Default.VideoLibrary),
@@ -113,7 +144,8 @@ private enum class Destination(val label: String, val icon: ImageVector) {
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProtonStreamApp(
- playerHost: NativeMpvHost? = null,
+ playerHost: LibmpvHost? = null,
+ inPictureInPicture: Boolean = false,
model: AppViewModel = viewModel(
factory = AppViewModel.Factory(
LocalContext.current,
@@ -123,14 +155,27 @@ fun ProtonStreamApp(
) {
val state by model.state.collectAsState()
val snackbars = remember { SnackbarHostState() }
- var destination by remember { mutableStateOf(Destination.Library) }
- var selectedTitle by remember { mutableStateOf(null) }
+ // Keys, not records: what has to survive process recreation is *which* title
+ // and which episode, and a `TitleRecord` is neither parcelable nor still
+ // current after the library reloads. Everything else is derived, so a reload
+ // that renames a season updates the open screen rather than pinning a stale
+ // copy of it.
+ var destination by rememberSaveable { mutableStateOf(Destination.Library) }
+ var selectedTitleKey by rememberSaveable { mutableStateOf(null) }
+ var playingTitleKey by rememberSaveable { mutableStateOf(null) }
+ var playingIndex by rememberSaveable { mutableIntStateOf(0) }
+ // Playing, but not on screen: the episode keeps going and the mini transport
+ // is what says so. Saved, because a rotation must not throw the viewer back
+ // into the video they had just left.
+ var playerMinimized by rememberSaveable { mutableStateOf(false) }
- LaunchedEffect(state.titles, selectedTitle?.key) {
- selectedTitle?.let { selected ->
- selectedTitle = state.titles.firstOrNull { it.key == selected.key } ?: selected
+ val selectedTitle = state.titles.firstOrNull { it.key == selectedTitleKey }
+ val playing = playingTitleKey
+ ?.let { key -> state.titles.firstOrNull { it.key == key } }
+ ?.let { title ->
+ val episodes = title.seasons.flatMap(SeasonRecord::episodes)
+ PlayRequest(title, episodes, playingIndex.coerceIn(0, (episodes.size - 1).coerceAtLeast(0)))
}
- }
LaunchedEffect(state.message) {
state.message?.let {
@@ -143,7 +188,7 @@ fun ProtonStreamApp(
Destination.entries.forEach { item ->
item(
selected = destination == item,
- onClick = { destination = item; selectedTitle = null },
+ onClick = { destination = item; selectedTitleKey = null },
icon = { Icon(item.icon, contentDescription = item.label) },
label = { Text(item.label) },
)
@@ -170,21 +215,52 @@ fun ProtonStreamApp(
)
},
) { padding ->
+ // Back from a secondary tab returns to the library rather than
+ // leaving the app; the library's own back is the system's, which is
+ // what a top-level destination should do.
+ BackHandler(enabled = destination != Destination.Library) {
+ destination = Destination.Library
+ }
+ val minimizedHost = (playerHost as? NativeMpvHost)?.takeIf { playing != null && playerMinimized }
+ Box(Modifier.fillMaxSize()) {
AnimatedContent(destination, label = "primary navigation") { target ->
when (target) {
Destination.Library -> if (selectedTitle == null) {
- LibraryScreen(state, model::search, { selectedTitle = it }, padding)
+ LibraryScreen(
+ state,
+ model::search,
+ { title, index ->
+ playingTitleKey = title.key
+ playingIndex = index
+ playerMinimized = false
+ },
+ { selectedTitleKey = it.key },
+ padding,
+ )
} else {
TitleScreen(
- selectedTitle!!,
- playerHost,
- { selectedTitle = null },
+ selectedTitle,
+ playerHost != null,
+ { _, index ->
+ playingTitleKey = selectedTitle.key
+ playingIndex = index
+ playerMinimized = false
+ },
+ { selectedTitleKey = null },
model::reportError,
model::reloadAfterMetadataChange,
+ model::setWatched,
padding,
)
}
- Destination.Shares -> SharesScreen(state.shares, model::addShare, model::removeShare, padding)
+ Destination.Shares -> SharesScreen(
+ state.shares,
+ model::addShare,
+ model::repairShare,
+ model::refreshShare,
+ model::removeShare,
+ padding,
+ )
Destination.Downloads -> DownloadsScreen(
state,
model::removeOffline,
@@ -193,21 +269,126 @@ fun ProtonStreamApp(
model::deletePartial,
padding,
)
- Destination.Settings -> SettingsScreen(state, model::saveMetadataSettings, padding)
+ Destination.Settings -> SettingsScreen(
+ state,
+ model::saveMetadataSettings,
+ { model.matchTitles(force = true) },
+ model::clearBlockCache,
+ model::removeAllOffline,
+ padding,
+ )
}
}
+ minimizedHost?.let { core ->
+ MiniTransport(
+ host = core,
+ onRestore = { playerMinimized = false },
+ onClose = {
+ playingTitleKey = null
+ playerMinimized = false
+ model.reloadAfterMetadataChange()
+ },
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .padding(bottom = padding.calculateBottomPadding() + 8.dp),
+ )
+ }
+ }
}
}
+ // Drawn over the scaffold rather than instead of it. The player owns the
+ // whole window while it is up — it is a screen, not a dialog over one, which
+ // is what lets it draw into the cutout and hand the right window to
+ // Picture-in-Picture — but the page behind it stays composed, so leaving the
+ // player finds the title page where it was rather than rebuilt from the top.
+ playing?.let { request ->
+ PlayerScreen(
+ title = request.title,
+ episodes = request.episodes,
+ index = request.index,
+ host = playerHost,
+ inPictureInPicture = inPictureInPicture,
+ minimized = playerMinimized,
+ onIndexChange = { playingIndex = it },
+ onSaveProgress = model::saveProgress,
+ onMinimize = { playerMinimized = true },
+ onClose = {
+ playingTitleKey = null
+ playerMinimized = false
+ model.reloadAfterMetadataChange()
+ },
+ )
+ }
}
+/**
+ * The bar that says playback is still going, and gets back to it.
+ *
+ * Leaving the player does not stop it — the episode keeps playing, which is the
+ * whole point of the background-audio setting — so there has to be something on
+ * screen that says so and takes one tap to return to. Without it, the only way
+ * back to a playing episode is to find it in the library again.
+ */
+@Composable
+private fun MiniTransport(
+ host: NativeMpvHost,
+ onRestore: () -> Unit,
+ onClose: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val state by host.state.collectAsState()
+ val playing by host.nowPlaying.collectAsState()
+ val episode = playing ?: return
+ Card(modifier.fillMaxWidth().padding(horizontal = 8.dp).clickable(onClick = onRestore)) {
+ Row(Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) {
+ RemoteArtwork(
+ episode.artworkUrl,
+ episode.show ?: episode.title,
+ Modifier.width(64.dp).aspectRatio(16f / 9f).clip(MaterialTheme.shapes.small),
+ fallback = episode.artworkFile,
+ )
+ Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
+ Text(episode.title, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ episode.show?.let {
+ Text(it, style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ }
+ }
+ IconButton(onClick = { host.setPaused(!state.paused) }) {
+ Icon(
+ if (state.paused) Icons.Default.PlayArrow else Icons.Default.Pause,
+ contentDescription = if (state.paused) "Play" else "Pause",
+ )
+ }
+ IconButton(onClick = onClose) {
+ Icon(Icons.Default.Close, contentDescription = "Stop playback")
+ }
+ }
+ }
+}
+
+/** One row of the continue-watching shelf: which episode, and where it sits. */
+private data class Resumable(val title: TitleRecord, val episode: EpisodeRecord, val index: Int)
+
@Composable
private fun LibraryScreen(
state: AppUiState,
onSearch: (String) -> Unit,
+ onResume: (TitleRecord, Int) -> Unit,
onTitle: (TitleRecord) -> Unit,
padding: PaddingValues,
) {
+ // Most recently played first, one episode per show: a shelf that lists four
+ // episodes of the same series is a shelf with room for nothing else.
+ val resumable = remember(state.titles) {
+ state.titles.mapNotNull { title ->
+ val playlist = title.seasons.flatMap(SeasonRecord::episodes)
+ playlist.withIndex()
+ .filter { it.value.resumeAt != null && !it.value.watched }
+ .maxByOrNull { it.value.lastPlayed }
+ ?.let { Resumable(title, it.value, it.index) }
+ }.sortedByDescending { it.episode.lastPlayed }.take(CONTINUE_WATCHING_MAX)
+ }
Column(Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp)) {
OutlinedTextField(
value = state.query,
@@ -218,6 +399,49 @@ private fun LibraryScreen(
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }
)
Spacer(Modifier.height(16.dp))
+ // Above the grid, and only when there is something in it: what a viewer
+ // opening the app wants is almost always the thing they were part way
+ // through, and finding it in an alphabetical grid is the long way round.
+ if (!state.loading && state.query.isBlank() && resumable.isNotEmpty()) {
+ Text("Continue watching", style = MaterialTheme.typography.titleMedium)
+ LazyRow(
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ contentPadding = PaddingValues(vertical = 12.dp),
+ ) {
+ items(resumable, key = { "${it.episode.shareId}/${it.episode.linkId}" }) { entry ->
+ Card(
+ Modifier.width(200.dp).clickable { onResume(entry.title, entry.index) },
+ ) {
+ RemoteArtwork(
+ entry.episode.stillUrl ?: entry.title.backdropUrl,
+ entry.title.canonicalName ?: entry.title.name,
+ Modifier.fillMaxWidth().aspectRatio(16f / 9f),
+ fallback = entry.episode.thumbnailSource,
+ )
+ Column(Modifier.padding(10.dp)) {
+ Text(
+ entry.title.canonicalName ?: entry.title.name,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Text(
+ entry.episode.label,
+ style = MaterialTheme.typography.bodySmall,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ entry.episode.progress?.let { progress ->
+ LinearProgressIndicator(
+ progress = { progress.toFloat().coerceIn(0f, 1f) },
+ modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
+ )
+ }
+ }
+ }
+ }
+ }
+ }
if (state.loading) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
} else if (state.titles.isEmpty()) {
@@ -235,6 +459,7 @@ private fun LibraryScreen(
title.backdropUrl ?: title.posterUrl,
title.canonicalName ?: title.name,
Modifier.fillMaxWidth().height(210.dp),
+ fallback = title.thumbnailSource,
)
Column(Modifier.padding(12.dp)) {
Text(title.name, fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis)
@@ -253,10 +478,12 @@ private fun LibraryScreen(
@Composable
private fun TitleScreen(
title: TitleRecord,
- playerHost: NativeMpvHost?,
+ playerReady: Boolean,
+ onPlay: (List, Int) -> Unit,
onBack: () -> Unit,
onPreferenceError: (Throwable) -> Unit,
onMetadataChanged: () -> Unit,
+ onSetWatched: (EpisodeRecord, Boolean) -> Unit,
padding: PaddingValues,
) {
val context = LocalContext.current
@@ -264,109 +491,57 @@ private fun TitleScreen(
var audioLanguage by remember(title.key) { mutableStateOf("") }
var subtitleLanguage by remember(title.key) { mutableStateOf("") }
var subtitlesEnabled by remember(title.key) { mutableStateOf(false) }
- var playing by remember(title.key) { mutableStateOf(null) }
var showMatch by remember(title.key) { mutableStateOf(false) }
var expandedSeasons by remember(title.key) { mutableStateOf(setOf(title.seasons.firstOrNull()?.label)) }
-
- BackHandler(enabled = playing == null, onBack = onBack)
-
+
+ BackHandler(onBack = onBack)
+
LaunchedEffect(title.key) {
runCatching {
withContext(Dispatchers.IO) {
NativeRuntime.engine().titleTrackPreferences(title.key)
}
}.onSuccess { preferences ->
- audioLanguage = preferences.audioLanguage.orEmpty()
- subtitleLanguage = preferences.subtitleLanguage.orEmpty()
- subtitlesEnabled = preferences.subtitles
+ // Absent means this title has never been given a choice; the fields
+ // are then empty and the global preference is what plays.
+ audioLanguage = preferences?.audioLanguage.orEmpty()
+ subtitleLanguage = preferences?.subtitleLanguage.orEmpty()
+ subtitlesEnabled = preferences?.subtitles ?: true
}.onFailure(onPreferenceError)
}
- if (playing != null) {
- androidx.compose.ui.window.Dialog(
- onDismissRequest = {
- playerHost?.stop()
- playing = null
- },
- properties = androidx.compose.ui.window.DialogProperties(
- usePlatformDefaultWidth = false,
- decorFitsSystemWindows = false
- )
- ) {
- val configuration = LocalConfiguration.current
- val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
- var isFullscreen by remember { mutableStateOf(isLandscape) }
- val activity = context as? android.app.Activity
-
- LaunchedEffect(isLandscape, isFullscreen) {
- val window = activity?.window ?: return@LaunchedEffect
- val controller = androidx.core.view.WindowInsetsControllerCompat(window, window.decorView)
- if (isLandscape || isFullscreen) {
- controller.hide(androidx.core.view.WindowInsetsCompat.Type.systemBars())
- controller.systemBarsBehavior = androidx.core.view.WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
- if (isFullscreen && !isLandscape) {
- activity.requestedOrientation = android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
- }
- } else {
- controller.show(androidx.core.view.WindowInsetsCompat.Type.systemBars())
- activity.requestedOrientation = android.content.pm.ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
- }
- }
-
- DisposableEffect(Unit) {
- onDispose {
- val window = activity?.window
- if (window != null) {
- val controller = androidx.core.view.WindowInsetsControllerCompat(window, window.decorView)
- controller.show(androidx.core.view.WindowInsetsCompat.Type.systemBars())
- }
- activity?.requestedOrientation = android.content.pm.ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
- }
- }
-
- BackHandler {
- if (isFullscreen && !isLandscape) {
- isFullscreen = false
- } else {
- playerHost?.stop()
- playing = null
- }
- }
-
- Box(Modifier.fillMaxSize().background(Color.Black)) {
- LibmpvPlayerSurface(
- title.key,
- playing!!,
- playerHost,
- isFullscreen = isLandscape || isFullscreen,
- onToggleFullscreen = { isFullscreen = !isFullscreen }
- )
- if (!isLandscape && !isFullscreen) {
- IconButton(
- onClick = { playerHost?.stop(); playing = null },
- Modifier.padding(padding).padding(16.dp).background(Color.Black.copy(alpha = 0.5f), androidx.compose.foundation.shape.CircleShape)
- ) {
- Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Close player", tint = Color.White)
- }
- }
- }
- }
- return
+ // Display order across seasons: what previous/next and autoplay walk, and
+ // the same order the desktop client uses.
+ val playlist = remember(title) { title.seasons.flatMap(SeasonRecord::episodes) }
+ // Where a press on Play lands. Part-watched wins over unwatched, most
+ // recently played first, which is the order the desktop client resumes in.
+ val nextUp = remember(playlist) {
+ val resumable = playlist.withIndex().filter { it.value.resumeAt != null }
+ resumable.maxByOrNull { it.value.lastPlayed }?.index
+ ?: playlist.indexOfFirst { !it.watched }.takeIf { it >= 0 }
+ ?: 0
}
+ // Live download state per episode, so a row can show progress and be paused
+ // without a trip to the Downloads tab.
+ val work by WorkManager.getInstance(context)
+ .getWorkInfosByTagLiveData(DownloadCoordinator.TAG).observeAsState(emptyList())
+ val downloads = remember(work) { DownloadStateStore(context).records() }
+
LazyColumn(
modifier = Modifier.fillMaxSize().padding(padding),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item {
- TextButton(onClick = onBack) {
+ TextButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
Spacer(Modifier.width(8.dp))
- Text("Back to library")
+ Text("Back to library")
}
RemoteArtwork(
title.backdropUrl ?: title.posterUrl,
title.canonicalName ?: title.name,
Modifier.fillMaxWidth().aspectRatio(16f / 9f).padding(top = 12.dp),
+ fallback = title.thumbnailSource,
)
Text(title.canonicalName ?: title.name, style = MaterialTheme.typography.headlineMedium, modifier = Modifier.padding(top = 12.dp))
title.originalName?.takeIf { it != title.canonicalName }?.let {
@@ -383,16 +558,57 @@ private fun TitleScreen(
)
title.overview?.let { Text(it, modifier = Modifier.padding(top = 10.dp)) }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 12.dp)) {
- Button(onClick = { DownloadCoordinator.enqueue(context, title.seasons.flatMap { it.episodes }) }) {
+ // What a press on the poster should play: whatever was left
+ // part-watched, else the first unwatched episode, else the
+ // first — the desktop client's `next_up`.
+ Button(onClick = { onPlay(playlist, nextUp) }, enabled = playerReady && playlist.isNotEmpty()) {
+ Text(
+ when {
+ !playerReady -> "Player loading…"
+ playlist.getOrNull(nextUp)?.resumeAt != null -> "Resume"
+ title.watchedCount > 0uL -> "Continue"
+ else -> "Play"
+ },
+ )
+ }
+ // Only where it would do something different: an unstarted show
+ // already starts at the beginning.
+ if (playlist.getOrNull(nextUp)?.resumeAt != null || title.watchedCount > 0uL) {
+ FilledTonalButton(
+ onClick = { onPlay(playlist, 0) },
+ enabled = playerReady && playlist.isNotEmpty(),
+ ) { Text("Start over") }
+ }
+ }
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 8.dp)) {
+ FilledTonalButton(onClick = { DownloadCoordinator.enqueue(context, playlist) }) {
Icon(Icons.Default.Download, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Download show")
}
- FilledTonalButton(onClick = { showMatch = true }) {
+ FilledTonalButton(onClick = { showMatch = true }) {
Icon(Icons.Default.Edit, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
- Text("Change match")
+ Text("Change match")
}
+ // The provider's own page for this title: where a viewer goes to
+ // check that the thing the app matched is the thing they have.
+ title.externalUrl?.let { url ->
+ TextButton(onClick = {
+ runCatching {
+ context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri()))
+ }.onFailure(onPreferenceError)
+ }) { Text("More on ${title.metadataProvider?.displayName() ?: "the provider"}") }
+ }
+ }
+ // Said plainly, because it changes what a re-match will do: a
+ // hand-picked match is not overwritten by an automatic pass.
+ if (title.manualMatch) {
+ Text(
+ "Matched by hand",
+ style = MaterialTheme.typography.labelMedium,
+ modifier = Modifier.padding(top = 8.dp),
+ )
}
Text("${title.watchedCount} of ${title.episodeCount} watched", Modifier.padding(vertical = 12.dp))
Text("Preferred tracks", style = MaterialTheme.typography.titleMedium)
@@ -461,21 +677,18 @@ private fun TitleScreen(
}
if (isExpanded) {
items(season.episodes, key = { it.linkId }) { episode ->
- Card(Modifier.fillMaxWidth()) {
- Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
- Button(onClick = { playing = episode }, enabled = playerHost != null) {
- Text(if (playerHost == null) "Player loading…" else "Play")
- }
- Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
- Text(episode.label, fontWeight = FontWeight.SemiBold)
- Text(episode.detail, style = MaterialTheme.typography.bodySmall, maxLines = 1)
- }
- FilledTonalButton(
- onClick = { DownloadCoordinator.enqueue(context, episode) },
- enabled = !episode.offline,
- ) { Text(if (episode.offline) "Offline" else "Download") }
- }
- }
+ EpisodeRow(
+ episode = episode,
+ download = downloads.firstOrNull {
+ it.shareId == episode.shareId && it.linkId == episode.linkId
+ },
+ playerReady = playerReady,
+ onPlay = { onPlay(playlist, playlist.indexOfFirst { it.linkId == episode.linkId }) },
+ onDownload = { DownloadCoordinator.enqueue(context, episode) },
+ onPause = { DownloadCoordinator.pause(context, it) },
+ onResume = { DownloadCoordinator.resume(context, it) },
+ onSetWatched = { onSetWatched(episode, it) },
+ )
}
}
}
@@ -490,6 +703,110 @@ private fun TitleScreen(
}
}
+/**
+ * One episode: play it, see where it was left, and drive its download.
+ *
+ * A row carries the things a viewer acts on — resume, mark seen, download,
+ * pause — so they are controls, not a menu: the download button becomes the
+ * progress readout while it runs, which is the same space either way.
+ */
+@Composable
+private fun EpisodeRow(
+ episode: EpisodeRecord,
+ download: RetainedDownload?,
+ playerReady: Boolean,
+ onPlay: () -> Unit,
+ onDownload: () -> Unit,
+ onPause: (RetainedDownload) -> Unit,
+ onResume: (RetainedDownload) -> Unit,
+ onSetWatched: (Boolean) -> Unit,
+) {
+ val running = download?.status == RetainedDownload.STATUS_RUNNING ||
+ download?.status == RetainedDownload.STATUS_QUEUED
+ Card(Modifier.fillMaxWidth()) {
+ Column(Modifier.padding(12.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ // The still, and the largest target on the row for the thing the
+ // row is for. With metadata off the provider has no still, and
+ // Proton's own thumbnail is a frame of this very episode.
+ RemoteArtwork(
+ episode.stillUrl,
+ episode.providerName ?: episode.label,
+ Modifier
+ .width(96.dp)
+ .aspectRatio(16f / 9f)
+ .clip(MaterialTheme.shapes.small)
+ .clickable(enabled = playerReady, onClick = onPlay),
+ fallback = episode.thumbnailSource,
+ )
+ Spacer(Modifier.width(12.dp))
+ Button(onClick = onPlay, enabled = playerReady) {
+ Text(
+ when {
+ !playerReady -> "Player loading…"
+ episode.watched -> "Replay"
+ episode.resumeAt != null -> "Resume"
+ else -> "Play"
+ },
+ )
+ }
+ Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
+ Text(episode.label, fontWeight = FontWeight.SemiBold)
+ // The provider's name for the episode when there is one:
+ // "The Cave of Skulls" says more than the filename it was
+ // parsed out of. The filename detail stays as the fallback.
+ Text(
+ episode.providerName ?: episode.detail,
+ style = MaterialTheme.typography.bodySmall,
+ maxLines = 1,
+ )
+ }
+ // Watched is a judgement the viewer is allowed to overrule: a
+ // half-watched episode they are done with, or one the 90 % rule
+ // marked seen because they sat through the credits.
+ IconButton(onClick = { onSetWatched(!episode.watched) }) {
+ Icon(
+ if (episode.watched) Icons.Default.CheckCircle else Icons.Outlined.CheckCircle,
+ contentDescription = if (episode.watched) "Mark unwatched" else "Mark watched",
+ tint = if (episode.watched) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.outline
+ },
+ )
+ }
+ when {
+ episode.offline -> Text("Offline", style = MaterialTheme.typography.labelMedium)
+ running -> FilledTonalButton(onClick = { onPause(download!!) }) { Text("Pause") }
+ download != null -> FilledTonalButton(onClick = { onResume(download) }) { Text("Resume") }
+ else -> FilledTonalButton(onClick = onDownload) {
+ Icon(Icons.Default.Download, contentDescription = "Download", modifier = Modifier.size(18.dp))
+ }
+ }
+ }
+ // Where the episode was left, under the row it belongs to.
+ episode.progress?.takeIf { !episode.watched }?.let { progress ->
+ LinearProgressIndicator(
+ progress = { progress.toFloat().coerceIn(0f, 1f) },
+ modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
+ )
+ }
+ download?.takeIf { it.total > 0L && !episode.offline }?.let { active ->
+ LinearProgressIndicator(
+ progress = { (active.downloaded.toFloat() / active.total).coerceIn(0f, 1f) },
+ modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
+ color = MaterialTheme.colorScheme.tertiary,
+ )
+ Text(
+ "${active.status.replaceFirstChar { it.uppercase() }} · " +
+ "${formatBytes(active.downloaded.toULong())} of ${formatBytes(active.total.toULong())}",
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ }
+ }
+}
+
@Composable
private fun ChangeMatchDialog(
title: TitleRecord,
@@ -554,10 +871,13 @@ private fun ChangeMatchDialog(
private fun SharesScreen(
shares: List,
onAdd: (String, String, String?) -> Unit,
+ onRepair: (String, String, String?) -> Unit,
+ onRefresh: (String) -> Unit,
onRemove: (String) -> Unit,
padding: PaddingValues,
) {
var showAdd by remember { mutableStateOf(false) }
+ var repairing by remember { mutableStateOf(null) }
Column(Modifier.fillMaxSize().padding(padding).padding(16.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Text("Proton Drive public links", style = MaterialTheme.typography.titleLarge)
@@ -571,6 +891,17 @@ private fun SharesScreen(
Text(share.name, fontWeight = FontWeight.SemiBold)
Text(if (share.hasCustomPassword) "Custom password stored securely" else "Public link")
}
+ // One share, not the library: a link that has just had
+ // files added to it should not cost a walk of every
+ // other one, and a link that has expired should not stop
+ // the ones that still work from being refreshed.
+ IconButton(onClick = { onRefresh(share.id) }) {
+ Icon(Icons.Default.Refresh, contentDescription = "Refresh this share")
+ }
+ // Not a remove: a share whose secret has become
+ // unreadable cannot be removed either, since removal
+ // deletes a secret the store can no longer touch.
+ TextButton(onClick = { repairing = share }) { Text("Re-enter link") }
FilledTonalButton(onClick = { onRemove(share.id) }) { Text("Remove") }
}
}
@@ -578,6 +909,68 @@ private fun SharesScreen(
}
}
if (showAdd) AddShareDialog(onDismiss = { showAdd = false }, onAdd = onAdd)
+ repairing?.let { share ->
+ RepairShareDialog(
+ share = share,
+ onDismiss = { repairing = null },
+ onRepair = { url, password -> onRepair(share.id, url, password) },
+ )
+ }
+}
+
+/**
+ * Re-enter the link for a share the app can no longer decrypt its secret for.
+ *
+ * It has to be the *same* link — a different token is a different share, and
+ * repointing this one at it would leave every catalog row and every offline file
+ * describing something that is not there. Rust enforces that; this only says so.
+ */
+@Composable
+private fun RepairShareDialog(
+ share: ShareRecord,
+ onDismiss: () -> Unit,
+ onRepair: (String, String?) -> Unit,
+) {
+ var url by remember { mutableStateOf("") }
+ var password by remember { mutableStateOf("") }
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
+ title = { Text("Re-enter link for ${share.name}") },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text(
+ "The stored credentials for this share cannot be read — usually after a " +
+ "screen-lock change or a device restore. Entering the same link again " +
+ "restores access without losing the library or anything downloaded.",
+ style = MaterialTheme.typography.bodySmall,
+ )
+ OutlinedTextField(
+ url,
+ { url = it },
+ label = { Text("Public share URL") },
+ singleLine = true,
+ visualTransformation = PasswordVisualTransformation(),
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
+ )
+ OutlinedTextField(
+ password,
+ { password = it },
+ label = { Text("Custom password (optional)") },
+ singleLine = true,
+ visualTransformation = PasswordVisualTransformation(),
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
+ )
+ }
+ },
+ confirmButton = {
+ Button(
+ onClick = { onRepair(url.trim(), password); onDismiss() },
+ enabled = url.isNotBlank(),
+ ) { Text("Restore") }
+ },
+ dismissButton = { FilledTonalButton(onClick = onDismiss) { Text("Cancel") } },
+ )
}
@Composable
@@ -638,29 +1031,59 @@ private fun DownloadsScreen(
// Reading retained metadata on every WorkInfo transition also hydrates
// paused/failed/cancelled entries after WorkManager history is pruned.
val retained = remember(downloads) { DownloadStateStore(context).records() }
- Column(Modifier.fillMaxSize().padding(padding).padding(16.dp).verticalScroll(rememberScrollState())) {
- Text("Offline downloads", style = MaterialTheme.typography.titleLarge)
- Spacer(Modifier.height(16.dp))
- if (retained.isEmpty() && state.offline.isEmpty()) {
- EmptyState("No downloads", "Episodes, seasons, and shows saved offline appear here.")
+ // Which show each saved file is from. The offline record knows its episode
+ // but not its show, and the library is the only thing that does.
+ val groups = remember(state.offline, state.titles) {
+ val shows = state.titles.flatMap { title ->
+ title.seasons.flatMap(SeasonRecord::episodes).map {
+ "${it.shareId}/${it.linkId}" to (title.canonicalName ?: title.name)
+ }
+ }.toMap()
+ state.offline
+ .groupBy { shows["${it.shareId}/${it.linkId}"] ?: "Not in the library" }
+ .toSortedMap()
+ }
+ LazyColumn(
+ modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp),
+ contentPadding = PaddingValues(bottom = 16.dp),
+ ) {
+ item {
+ Text("Offline downloads", style = MaterialTheme.typography.titleLarge)
+ Spacer(Modifier.height(16.dp))
}
- state.offline.forEach { file ->
- val episode = file.episode
- Card(Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
- Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
- Column(Modifier.weight(1f)) {
- Text(episode?.label ?: file.linkId, fontWeight = FontWeight.SemiBold)
- Text(formatBytes(file.size))
- }
- FilledTonalButton(onClick = { onRemove(file) }) {
- Icon(Icons.Default.Delete, contentDescription = null, modifier = Modifier.size(18.dp))
- Spacer(Modifier.width(8.dp))
- Text("Delete")
+ if (retained.isEmpty() && state.offline.isEmpty()) {
+ item { EmptyState("No downloads", "Episodes, seasons, and shows saved offline appear here.") }
+ }
+ // Under the show they belong to, as on desktop: a saved season is
+ // fourteen rows, and fourteen rows of "Episode 3" name nothing.
+ groups.forEach { (show, files) ->
+ item(key = "group/$show") {
+ Text(show, style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 4.dp))
+ Text(
+ "${files.size} ${if (files.size == 1) "episode" else "episodes"} · " +
+ formatBytes(files.sumOf { it.size }),
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.padding(bottom = 8.dp),
+ )
+ }
+ items(files, key = { "${it.shareId}/${it.linkId}" }) { file ->
+ val episode = file.episode
+ Card(Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
+ Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
+ Column(Modifier.weight(1f)) {
+ Text(episode?.label ?: file.linkId, fontWeight = FontWeight.SemiBold)
+ Text(formatBytes(file.size))
+ }
+ FilledTonalButton(onClick = { onRemove(file) }) {
+ Icon(Icons.Default.Delete, contentDescription = null, modifier = Modifier.size(18.dp))
+ Spacer(Modifier.width(8.dp))
+ Text("Delete")
+ }
}
}
}
}
- retained.forEach { download ->
+ items(retained, key = { "${it.shareId}/${it.linkId}" }) { download ->
val progress = if (download.total > 0L) download.downloaded.toFloat() / download.total else 0f
Card(Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
Column(Modifier.padding(16.dp)) {
@@ -697,17 +1120,43 @@ private fun DownloadsScreen(
private fun SettingsScreen(
state: AppUiState,
onSaveMetadata: (Boolean, MetadataProvider, String, String) -> Unit,
+ onMatchAgain: () -> Unit,
+ onClearCache: () -> Unit,
+ onRemoveAllOffline: () -> Unit,
padding: PaddingValues,
) {
val context = LocalContext.current
val settings = remember { SettingsStore(context) }
var wifiOnly by remember { mutableStateOf(settings.wifiOnly) }
var backgroundAudio by remember { mutableStateOf(settings.backgroundAudio) }
+ // Playback preferences are the shared store's, not this app's: they are the
+ // same file the desktop client reads, so a language chosen on one is the
+ // language the other starts in.
+ var prefs by remember { mutableStateOf(null) }
+ val scope = rememberCoroutineScope()
+ fun update(change: (PlaybackPrefsRecord) -> PlaybackPrefsRecord) {
+ val next = change(prefs ?: return)
+ prefs = next
+ scope.launch(Dispatchers.IO) {
+ runCatching { NativeRuntime.engine().setPlaybackPrefs(next) }
+ }
+ }
+ LaunchedEffect(Unit) {
+ runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().playbackPrefs() } }
+ .onSuccess { prefs = it }
+ }
+ var confirmDelete by remember { mutableStateOf(false) }
var showMetadata by remember { mutableStateOf(false) }
var legalDocument by remember { mutableStateOf(null) }
Column(Modifier.fillMaxSize().padding(padding).padding(16.dp).verticalScroll(rememberScrollState())) {
Text("Settings", style = MaterialTheme.typography.titleLarge)
- SettingToggle("Download on Wi-Fi only", wifiOnly) { wifiOnly = it; settings.wifiOnly = it }
+ SettingToggle("Download on Wi-Fi only", wifiOnly) {
+ wifiOnly = it
+ settings.wifiOnly = it
+ // Constraints are baked in at enqueue time, so a queue that already
+ // exists keeps the policy it was queued under until it is re-issued.
+ DownloadCoordinator.applyNetworkPolicy(context)
+ }
HorizontalDivider()
SettingToggle("Continue audio in the background", backgroundAudio) {
backgroundAudio = it
@@ -719,6 +1168,44 @@ private fun SettingsScreen(
modifier = Modifier.padding(bottom = 14.dp),
)
HorizontalDivider()
+ Text("Playback", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 20.dp))
+ prefs?.let { current ->
+ SettingToggle("Play the next episode automatically", current.autoplayNext) { on ->
+ update { it.copy(autoplayNext = on) }
+ }
+ SettingToggle("Skip openings and endings automatically", current.autoSkip) { on ->
+ update { it.copy(autoSkip = on) }
+ }
+ Text(
+ "Openings and endings are read from the chapters a release was muxed with. " +
+ "With this off you get a Skip button instead, which is the safer default: " +
+ "a mis-named chapter then costs a tap rather than a scene.",
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.padding(bottom = 14.dp),
+ )
+ SettingToggle("Show subtitles", current.subtitles) { on ->
+ update { it.copy(subtitles = on) }
+ }
+ // Language tags, not a picker: which languages exist is a property
+ // of each file, and a list built from one episode is wrong for the
+ // next. A show that has been given its own choice keeps it.
+ LanguageField("Preferred audio language", current.audioLanguage) { tag ->
+ update { it.copy(audioLanguage = tag) }
+ }
+ LanguageField("Preferred subtitle language", current.subtitleLanguage) { tag ->
+ update { it.copy(subtitleLanguage = tag) }
+ }
+ Text(
+ "Three-letter tags as they appear in the file — \"jpn\", \"eng\". Used for every " +
+ "title that has not been given a track choice of its own.",
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.padding(bottom = 14.dp),
+ )
+ }
+ HorizontalDivider()
+ Text("Appearance", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 20.dp))
+ AppearancePicker()
+ HorizontalDivider()
Text("Metadata enrichment", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 20.dp))
Text(
if (state.metadataSettings.enabled) "On · ${state.metadataSettings.provider.displayName()}"
@@ -728,12 +1215,42 @@ private fun SettingsScreen(
"Enabling this sends library title names to the selected third-party provider over HTTPS.",
style = MaterialTheme.typography.bodySmall,
)
- FilledTonalButton(onClick = { showMetadata = true }, modifier = Modifier.padding(vertical = 10.dp)) {
- Text("Configure metadata")
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(vertical = 10.dp)) {
+ FilledTonalButton(onClick = { showMetadata = true }) { Text("Configure metadata") }
+ // Every title looked up again, matched ones included: the way out of
+ // a library the provider answered wrong, which otherwise stays wrong
+ // for as long as the match is remembered.
+ FilledTonalButton(
+ onClick = { onMatchAgain() },
+ enabled = state.metadataSettings.enabled && !state.refreshing,
+ ) { Text("Match everything again") }
}
HorizontalDivider()
Text("Storage", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 20.dp))
Text("Offline media is encrypted at rest by Android and kept in app-private storage.")
+ Text(
+ "${state.storage.offlineCount} episodes offline · ${formatBytes(state.storage.offlineBytes)}",
+ modifier = Modifier.padding(top = 8.dp),
+ )
+ if (state.storage.partialBytes > 0uL) {
+ Text(
+ "Unfinished downloads · ${formatBytes(state.storage.partialBytes)}",
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ Text(
+ "Streaming cache · ${formatBytes(state.storage.cacheBytes)}",
+ style = MaterialTheme.typography.bodySmall,
+ )
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 10.dp)) {
+ // The cache is rebuildable, so it goes without asking. Offline
+ // episodes are a choice the viewer made, so that one asks.
+ FilledTonalButton(onClick = onClearCache) { Text("Clear cache") }
+ FilledTonalButton(
+ onClick = { confirmDelete = true },
+ enabled = state.storage.offlineCount > 0uL,
+ ) { Text("Delete all offline") }
+ }
Text("proton-stream Android · GPL-3.0-or-later", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(top = 24.dp))
Text(
"This program comes with absolutely no warranty. You may redistribute it under the GNU GPL.",
@@ -749,6 +1266,23 @@ private fun SettingsScreen(
}) { Text("View notices") }
}
}
+ if (confirmDelete) {
+ AlertDialog(
+ onDismissRequest = { confirmDelete = false },
+ title = { Text("Delete all offline episodes?") },
+ text = {
+ Text(
+ "${state.storage.offlineCount} episodes (${formatBytes(state.storage.offlineBytes)}) " +
+ "will be removed from this device. Watch history is kept, and anything " +
+ "deleted can be downloaded again.",
+ )
+ },
+ confirmButton = {
+ Button(onClick = { confirmDelete = false; onRemoveAllOffline() }) { Text("Delete") }
+ },
+ dismissButton = { FilledTonalButton(onClick = { confirmDelete = false }) { Text("Cancel") } },
+ )
+ }
legalDocument?.let { document ->
LegalDocumentDialog(document, onDismiss = { legalDocument = null })
}
@@ -854,6 +1388,120 @@ private fun SettingToggle(label: String, checked: Boolean, onChecked: (Boolean)
}
}
+/**
+ * Flavour, accent and gradients — the same three the desktop client offers.
+ *
+ * Every colour is resolved by Rust, so a swatch here is the colour the app will
+ * actually paint rather than an approximation of it, and the choice is stored in
+ * the file both clients read.
+ */
+@Composable
+private fun AppearancePicker() {
+ val scope = rememberCoroutineScope()
+ var choice by remember { mutableStateOf(null) }
+ var swatches by remember { mutableStateOf