mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-07 09:22:10 +01:00
improved android client, feature parity with desktop
This commit is contained in:
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),
|
||||
)
|
||||
Reference in new issue
Block a user