improved android client, feature parity with desktop

This commit is contained in:
nvrl committed 2026-08-09 16:40:37 +02:00
1 parent 352fdf1933
commit f1b3ad9637
56 files changed
+9727 -1799

No files matched your search

@@ -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<NativeMpvHost?>(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
}
}
@@ -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<OfflineDownloadWorker>()
}
fun enqueue(context: Context, episodes: Iterable<EpisodeRecord>) {
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<OfflineDownloadWorker>()
.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<EpisodeRecord>) {
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 =
@@ -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<RetainedDownload>) = 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()
}
}
@@ -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
}
}
@@ -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 <T> 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 {
@@ -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) {
@@ -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<String?>(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<String?>(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<MpvTrack>,
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)
}
@@ -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<MpvTrack>())
private val mutableChapters = MutableStateFlow(ChapterPlan(emptyList(), null))
private val mutableNowPlaying = MutableStateFlow<NowPlaying?>(null)
private val mutableAdvancing = MutableStateFlow(false)
private val mutableProblem = MutableStateFlow<String?>(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<Double, Long>? = 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<MpvPlaybackState> = mutableState.asStateFlow()
val tracks: StateFlow<List<MpvTrack>> = mutableTracks.asStateFlow()
/** The open file's chapters, already resolved into openings and endings. */
val chapters: StateFlow<ChapterPlan> = mutableChapters.asStateFlow()
/** The episode the player has open, for every surface that has to name it. */
val nowPlaying: StateFlow<NowPlaying?> = 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<Boolean> = 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<String?> = 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
}
}
@@ -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<String, Bitmap>? = 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"
}
}
@@ -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<EpisodeRecord>,
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<String?>(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<Long>() }
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<ChapterEntry>,
tracks: List<MpvTrack>,
hasPrevious: Boolean,
hasNext: Boolean,
onPrevious: () -> Unit,
onNext: () -> Unit,
onRotate: () -> Unit,
landscapeLocked: Boolean,
) {
val scope = rememberCoroutineScope()
var choosing by remember { mutableStateOf<String?>(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<Float?>(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<Float?>(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<ChapterEntry>, 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<ChapterEntry>,
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<MpvTrack>,
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)
}
@@ -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}"
@@ -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)
@@ -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<AppUiState> = 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<TitleRecord>,
val offline: List<OfflineRecord>,
val metadataSettings: MetadataSettingsRecord,
val storage: StorageUsageRecord,
)
private fun WorkManager.workInfosByTagFlow(tag: String) = callbackFlow {
File diff suppressed because it is too large. Load diff
@@ -1,6 +1,9 @@
package io.narl.protonstream.ui
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.LruCache
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
@@ -19,50 +22,61 @@ import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import io.narl.protonstream.native.NativeRuntime
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.TitleRecord
import java.net.HttpURLConnection
import java.net.URI
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.InputStream
import java.security.MessageDigest
import java.util.Collections
/** HTTPS-only artwork loader backed by the app-private cache directory. */
/**
* One file to pull Proton's own thumbnail from when no provider poster exists.
*
* Proton renders a thumbnail per file, so any episode of a show is a frame of
* the right show — the desktop client picks the first for the same reason.
*/
data class ThumbnailSource(val shareId: String, val volumeId: String, val linkId: String) {
val key: String get() = "$shareId/$linkId"
}
/**
* What the first episode of a title offers as a poster.
*
* Metadata lookups are off by default, so for a freshly crawled library this is
* the only artwork there is.
*/
internal val TitleRecord.thumbnailSource: ThumbnailSource?
get() = seasons.firstNotNullOfOrNull { season -> season.episodes.firstOrNull() }
?.let { ThumbnailSource(it.shareId, it.volumeId, it.linkId) }
/** What one episode offers as a still. */
internal val EpisodeRecord.thumbnailSource: ThumbnailSource
get() = ThumbnailSource(shareId, volumeId, linkId)
/**
* HTTPS-only artwork loader backed by the app-private cache directory.
*
* [fallback] is used only when there is no provider artwork or it fails to
* load, which — with metadata off — is the common case rather than the rare
* one.
*/
@Composable
internal fun RemoteArtwork(url: String?, description: String, modifier: Modifier = Modifier) {
internal fun RemoteArtwork(
url: String?,
description: String,
modifier: Modifier = Modifier,
fallback: ThumbnailSource? = null,
) {
val context = LocalContext.current
var image by remember(url) { mutableStateOf<ImageBitmap?>(null) }
LaunchedEffect(url) {
image = url?.let { requested ->
withContext(Dispatchers.IO) {
runCatching {
val uri = URI(requested)
require(uri.scheme.equals("https", ignoreCase = true)) { "Artwork must use HTTPS" }
val directory = context.cacheDir.resolve("metadata-art").apply { mkdirs() }
val digest = MessageDigest.getInstance("SHA-256")
.digest(requested.toByteArray()).joinToString("") { "%02x".format(it) }
val cached = directory.resolve("$digest.img")
val bytes = if (cached.isFile) cached.readBytes() else {
val connection = uri.toURL().openConnection() as HttpURLConnection
connection.connectTimeout = 10_000
connection.readTimeout = 10_000
connection.instanceFollowRedirects = true
val status = connection.responseCode
require(status in 200..299) { "Artwork request failed ($status)" }
require(connection.url.protocol.equals("https", ignoreCase = true)) {
"Artwork redirect must use HTTPS"
}
connection.inputStream.use(::readBounded).also { downloaded ->
require(downloaded.size <= MAX_ART_BYTES) { "Artwork is too large" }
val temporary = directory.resolve("$digest.part")
temporary.writeBytes(downloaded)
check(temporary.renameTo(cached)) { "Unable to cache artwork" }
}
}
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap()
}.getOrNull()
}
}
var image by remember(url, fallback) { mutableStateOf<ImageBitmap?>(null) }
LaunchedEffect(url, fallback) {
image = (loadArtwork(context, url) ?: loadThumbnail(fallback))?.asImageBitmap()
}
Box(modifier.background(MaterialTheme.colorScheme.surfaceVariant), contentAlignment = Alignment.Center) {
image?.let {
@@ -71,7 +85,153 @@ internal fun RemoteArtwork(url: String?, description: String, modifier: Modifier
}
}
/**
* The same HTTPS-only fetch behind the library grid, reusable off the composition.
*
* The media notification needs a bitmap rather than an [ImageBitmap], and needs
* it from a service with no composition of its own — but it wants exactly this
* loader's protocol checks and exactly this disk cache, so the artwork the shade
* shows costs no second download.
*/
internal suspend fun loadArtwork(context: Context, url: String?): Bitmap? = url?.let { requested ->
decoded.get(requested) ?: withContext(Dispatchers.IO) {
runCatching {
val uri = URI(requested)
require(uri.scheme.equals("https", ignoreCase = true)) { "Artwork must use HTTPS" }
val directory = context.cacheDir.resolve("metadata-art").apply { mkdirs() }
val digest = MessageDigest.getInstance("SHA-256")
.digest(requested.toByteArray()).joinToString("") { "%02x".format(it) }
val cached = directory.resolve("$digest.img")
val fresh = !cached.isFile
val bytes = if (fresh) download(uri) else cached.readBytes()
val bitmap = requireNotNull(decodeSampled(bytes)) { "Artwork is not a decodable image" }
if (fresh) {
// Written only once it has decoded. Caching the bytes first is
// what turns one hostile poster into a crash on every launch
// that survives until app data is cleared.
val temporary = directory.resolve("$digest.part")
temporary.writeBytes(bytes)
check(temporary.renameTo(cached)) { "Unable to cache artwork" }
prune(directory)
} else {
cached.setLastModified(System.currentTimeMillis())
}
bitmap
}.getOrNull()?.also { decoded.put(requested, it) }
}
}
/**
* Proton's own thumbnail for one file, decoded.
*
* The bridge holds the disk cache, so what is worth keeping here is the decoded
* bitmap and the *absence* of one: a file with no thumbnail is common, and
* without remembering that, every scroll back over the grid pays a round trip
* per tile to be told so again. A failed call is not remembered — an unreachable
* share or a session that has to be reopened is transient, and the next
* composition should try again.
*/
internal suspend fun loadThumbnail(source: ThumbnailSource?): Bitmap? {
val file = source ?: return null
val key = file.key
decoded.get(key)?.let { return it }
if (key in missing) return null
val fetched = withContext(Dispatchers.IO) {
runCatching { NativeRuntime.engine().thumbnail(file.shareId, file.volumeId, file.linkId) }
}
val bytes = fetched.getOrElse { return null }
val bitmap = bytes?.let { withContext(Dispatchers.Default) { decodeSampled(it) } }
if (bitmap == null) {
missing.add(key)
return null
}
decoded.put(key, bitmap)
return bitmap
}
private fun download(uri: URI): ByteArray {
val connection = uri.toURL().openConnection() as HttpURLConnection
connection.connectTimeout = 10_000
connection.readTimeout = 10_000
connection.instanceFollowRedirects = true
val status = connection.responseCode
require(status in 200..299) { "Artwork request failed ($status)" }
require(connection.url.protocol.equals("https", ignoreCase = true)) {
"Artwork redirect must use HTTPS"
}
return connection.inputStream.use(::readBounded)
.also { require(it.size <= MAX_ART_BYTES) { "Artwork is too large" } }
}
/**
* Decode within a bound the display can actually use.
*
* [MAX_ART_BYTES] bounds the *compressed* image, which says nothing about what
* it costs decoded: a 12 MiB PNG can be 12000×12000, and that is a 576 MB
* `ARGB_8888` allocation on a device that will not give it. The bounds pass
* costs no allocation and settles a sample size before anything is committed.
*/
private fun decodeSampled(bytes: ByteArray): Bitmap? {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
val longest = maxOf(bounds.outWidth, bounds.outHeight)
if (longest <= 0) return null
var sample = 1
while (longest / sample > MAX_ART_EDGE) sample *= 2
return BitmapFactory.decodeByteArray(
bytes,
0,
bytes.size,
BitmapFactory.Options().apply { inSampleSize = sample },
)
}
/**
* Hold the cache directory under [MAX_ART_CACHE_BYTES], oldest first.
*
* `cacheDir` is reclaimable by the system, but only under storage pressure and
* only wholesale — a poster cache that grows with every title ever browsed is
* still the app's own doing.
*/
private fun prune(directory: File) {
val files = directory.listFiles()?.sortedBy(File::lastModified) ?: return
var total = files.sumOf(File::length)
for (file in files) {
if (total <= MAX_ART_CACHE_BYTES) return
total -= file.length()
file.delete()
}
}
/**
* Files known to have no Proton thumbnail, so the answer is paid for once.
*
* Deliberately not persisted: a re-upload can add one, and the cost of being
* wrong for a session is a placeholder that a restart clears.
*/
private val missing: MutableSet<String> = Collections.synchronizedSet(mutableSetOf())
/**
* Decoded posters, so scrolling the grid re-reads neither disk nor decoder.
*
* Keyed by artwork URL or by `share/link` for a Proton thumbnail; the two
* cannot collide, since one is always an `https:` URL.
*
* `remember(url)` is per-composition, and a `LazyVerticalGrid` drops and
* recreates items as they leave the viewport — without this, every scroll back
* is a full-resolution decode per item.
*/
private val decoded = object : LruCache<String, Bitmap>(
(Runtime.getRuntime().maxMemory() / 8).coerceIn(4L * 1024 * 1024, 32L * 1024 * 1024).toInt(),
) {
override fun sizeOf(key: String, value: Bitmap): Int = value.byteCount
}
private const val MAX_ART_BYTES = 12 * 1024 * 1024
private const val MAX_ART_CACHE_BYTES = 48L * 1024 * 1024
/** Longest edge worth decoding: a poster on a phone, or a notification icon. */
private const val MAX_ART_EDGE = 1_024
/** Reads at most [MAX_ART_BYTES] plus one byte without requiring API 33's readNBytes. */
private fun readBounded(input: InputStream): ByteArray {
@@ -1,24 +1,103 @@
package io.narl.protonstream.ui.theme
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.graphics.Color
import io.narl.protonstream.native.NativeRuntime
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
import uniffi.pstr_android.PaletteRecord
private val ProtonColors = darkColorScheme(
primary = Color(0xFFCBA6F7),
onPrimary = Color(0xFF1E1E2E),
secondary = Color(0xFFF5C2E7),
background = Color(0xFF1E1E2E),
surface = Color(0xFF181825),
surfaceVariant = Color(0xFF313244),
onBackground = Color(0xFFCDD6F4),
onSurface = Color(0xFFCDD6F4),
outline = Color(0xFF6C7086),
error = Color(0xFFF38BA8),
)
/**
* The palette in force, shared across every composition.
*
* The choice is stored by Rust and resolved by Rust — the same flavours, the
* same accent pairings and the same contrast rule the desktop client uses — so
* what lives here is only the resolved answer and a way to replace it when the
* viewer picks something else.
*/
object AppearanceState {
private val current = MutableStateFlow<PaletteRecord?>(null)
val palette: StateFlow<PaletteRecord?> = current.asStateFlow()
/** Read the stored choice. Safe to call repeatedly; the last read wins. */
suspend fun load() {
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().palette() } }
.onSuccess { current.value = it }
}
/** Repaint immediately, before the write that stores the choice lands. */
fun apply(palette: PaletteRecord) {
current.value = palette
}
}
@Composable
fun ProtonStreamTheme(content: @Composable () -> Unit) {
MaterialTheme(colorScheme = ProtonColors, content = content)
val palette by AppearanceState.palette.collectAsState()
// Before the first read, and if it fails, the palette this app shipped
// with — a theme file that cannot be read must never be a reason the
// library does not open.
LaunchedEffect(Unit) { AppearanceState.load() }
MaterialTheme(colorScheme = palette?.let(::schemeOf) ?: ProtonColors, content = content)
}
/**
* One resolved palette as Material's roles.
*
* Material names more roles than a palette has colours, so several are answered
* by the same one — `surfaceVariant` and `card` above all, which is what makes
* a tile and a form field the same shade in both clients.
*/
private fun schemeOf(palette: PaletteRecord): ColorScheme {
val base = if (palette.light) lightColorScheme() else darkColorScheme()
return base.copy(
primary = color(palette.accent),
onPrimary = color(palette.onAccent),
primaryContainer = color(palette.accentDim),
onPrimaryContainer = color(palette.text),
secondary = color(palette.accentAlt),
onSecondary = color(palette.onAccent),
secondaryContainer = color(palette.cardHover),
onSecondaryContainer = color(palette.text),
background = color(palette.background),
onBackground = color(palette.text),
surface = color(palette.surface),
onSurface = color(palette.text),
surfaceVariant = color(palette.card),
onSurfaceVariant = color(palette.muted),
surfaceContainer = color(palette.card),
surfaceContainerHigh = color(palette.cardHover),
surfaceContainerLow = color(palette.sunken),
outline = color(palette.border),
outlineVariant = color(palette.muted),
error = color(palette.danger),
onError = color(palette.background),
)
}
/** A packed `0xAARRGGBB` from the bridge, as Compose wants it. */
private fun color(argb: UInt) = Color(argb.toInt())
/** The shipped default, spelled out for the frames before the store is read. */
private val ProtonColors = darkColorScheme(
primary = Color(0xFF7D4DFF),
onPrimary = Color(0xFFEAEAF0),
secondary = Color(0xFF3F6FE0),
background = Color(0xFF0E0E12),
surface = Color(0xFF17171D),
surfaceVariant = Color(0xFF1E1E26),
onBackground = Color(0xFFEAEAF0),
onSurface = Color(0xFFEAEAF0),
outline = Color(0xFF2A2A35),
error = Color(0xFFE05561),
)