added android client

This commit is contained in:
nvrl committed 2026-08-05 13:07:56 +02:00
1 parent 8b95cf7a05
commit 6db6909824
73 files changed
+9234 -106

No files matched your search

@@ -0,0 +1,83 @@
package io.narl.protonstream
import android.Manifest
import android.content.pm.PackageManager
import android.os.Bundle
import android.os.Build
import android.app.PictureInPictureParams
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
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 io.narl.protonstream.playback.NativeMpvHost
import io.narl.protonstream.playback.PlaybackService
import androidx.compose.runtime.mutableStateOf
import io.narl.protonstream.settings.SettingsStore
class MainActivity : ComponentActivity() {
private val playerHost = mutableStateOf<NativeMpvHost?>(null)
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
}
}
private val notificationPermission = registerForActivityResult(
ActivityResultContracts.RequestPermission(),
) { /* Downloads remain usable; Android suppresses their notifications if denied. */ }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
bindService(Intent(this, PlaybackService::class.java), playbackConnection, Context.BIND_AUTO_CREATE)
if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
) {
notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
}
setContent {
ProtonStreamTheme {
ProtonStreamApp(playerHost.value)
}
}
}
override fun onUserLeaveHint() {
super.onUserLeaveHint()
if (playerHost.value?.hasActivePlayback == true && !isInPictureInPictureMode) {
enterPictureInPictureMode(
PictureInPictureParams.Builder().setAspectRatio(Rational(16, 9)).setAutoEnterEnabled(true).build(),
)
}
}
override fun onDestroy() {
if (bound) unbindService(playbackConnection)
super.onDestroy()
}
override fun onStop() {
super.onStop()
if (
!isChangingConfigurations &&
!isInPictureInPictureMode &&
!SettingsStore(this).backgroundAudio
) {
playerHost.value?.stop()
}
}
}
@@ -0,0 +1,11 @@
package io.narl.protonstream
import android.app.Application
import io.narl.protonstream.native.NativeRuntime
class ProtonStreamApplication : Application() {
override fun onCreate() {
super.onCreate()
NativeRuntime.initialize(this)
}
}
@@ -0,0 +1,78 @@
package io.narl.protonstream.download
import android.content.Context
import androidx.work.Constraints
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.workDataOf
import io.narl.protonstream.settings.SettingsStore
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,
)
}
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 request = OneTimeWorkRequestBuilder<OfflineDownloadWorker>()
.setConstraints(
Constraints.Builder().setRequiredNetworkType(
requiredNetworkType(SettingsStore(context).wifiOnly),
).build(),
)
.setInputData(
workDataOf(
OfflineDownloadWorker.KEY_SHARE_ID to download.shareId,
OfflineDownloadWorker.KEY_VOLUME_ID to download.volumeId,
OfflineDownloadWorker.KEY_LINK_ID to download.linkId,
OfflineDownloadWorker.KEY_LABEL to download.label,
),
)
.addTag(TAG)
.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"
const val TAG = "offline-download"
const val EPISODE_TAG = "offline-episode:"
private const val SHARE_TAG = "offline-share:"
}
internal fun requiredNetworkType(wifiOnly: Boolean): NetworkType =
if (wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED
@@ -0,0 +1,82 @@
package io.narl.protonstream.download
import android.content.Context
import org.json.JSONObject
/** Durable user-facing state retained after WorkManager prunes finished work. */
data class RetainedDownload(
val shareId: String,
val volumeId: String,
val linkId: String,
val label: String,
val downloaded: Long = 0,
val total: Long = 0,
val status: String = STATUS_QUEUED,
val error: String? = null,
) {
val key: String get() = "$shareId\u001f$linkId"
companion object {
const val STATUS_QUEUED = "queued"
const val STATUS_RUNNING = "running"
const val STATUS_PAUSED = "paused"
const val STATUS_FAILED = "failed"
const val STATUS_CANCELLED = "cancelled"
}
}
class DownloadStateStore(context: Context) {
private val preferences = context.getSharedPreferences(NAME, Context.MODE_PRIVATE)
fun records(): List<RetainedDownload> = preferences.all.values.mapNotNull { encoded ->
runCatching { decode(encoded as String) }.getOrNull()
}.sortedBy { it.label.lowercase() }
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 remove(shareId: String, linkId: String) {
preferences.edit().remove(key(shareId, linkId)).commit()
}
fun removeShare(shareId: String) {
val edit = preferences.edit()
records().filter { it.shareId == shareId }.forEach { edit.remove(it.key) }
edit.commit()
}
private fun encode(record: RetainedDownload) = JSONObject()
.put("share", record.shareId)
.put("volume", record.volumeId)
.put("link", record.linkId)
.put("label", record.label)
.put("downloaded", record.downloaded)
.put("total", record.total)
.put("status", record.status)
.put("error", record.error)
.toString()
private fun decode(encoded: String): RetainedDownload {
val json = JSONObject(encoded)
return RetainedDownload(
shareId = json.getString("share"),
volumeId = json.getString("volume"),
linkId = json.getString("link"),
label = json.optString("label", json.getString("link")),
downloaded = json.optLong("downloaded"),
total = json.optLong("total"),
status = json.optString("status", RetainedDownload.STATUS_QUEUED),
error = json.optString("error").takeIf(String::isNotBlank),
)
}
private fun key(shareId: String, linkId: String) = "$shareId\u001f$linkId"
private companion object {
const val NAME = "offline_download_state"
}
}
@@ -0,0 +1,116 @@
package io.narl.protonstream.download
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.pm.ServiceInfo
import android.content.Context
import androidx.core.app.NotificationCompat
import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import io.narl.protonstream.native.NativeRuntime
import uniffi.pstr_android.DownloadObserver
import java.util.UUID
/**
* Durable boundary for native offline downloads. Rust owns the block-aligned transfer and
* catalog mutation; WorkManager owns constraints, retries and user-visible lifecycle.
*/
class OfflineDownloadWorker(
appContext: Context,
parameters: WorkerParameters,
) : CoroutineWorker(appContext, parameters) {
override suspend fun doWork(): Result {
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"))
val linkId = inputData.getString(KEY_LINK_ID) ?: return Result.failure(error("missing link"))
val label = inputData.getString(KEY_LABEL) ?: linkId
val store = DownloadStateStore(applicationContext)
var retained = store.get(shareId, linkId)
?: RetainedDownload(shareId, volumeId, linkId, label)
retained = retained.copy(status = RetainedDownload.STATUS_RUNNING, error = null)
store.put(retained)
val observer = object : DownloadObserver {
override fun onProgress(downloaded: ULong, total: ULong) {
val percent = if (total == 0UL) 0 else ((downloaded * 100UL) / total).toInt()
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)
}
override fun isCancelled(): Boolean = isStopped
}
return runCatching {
NativeRuntime.engine().downloadEpisode(shareId, volumeId, linkId, observer)
}.fold(
onSuccess = {
store.remove(shareId, linkId)
Result.success()
},
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))
}
Result.failure(error("cancelled"))
} else if (runAttemptCount < MAX_RETRIES) {
store.put(retained.copy(status = RetainedDownload.STATUS_QUEUED, error = failure.message))
Result.retry()
} else {
val message = failure.message ?: "download failed"
store.put(retained.copy(status = RetainedDownload.STATUS_FAILED, error = message))
Result.failure(error(message))
}
},
)
}
private fun createForegroundInfo(progress: Int): ForegroundInfo {
val manager = applicationContext.getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(
NotificationChannel(CHANNEL_ID, "Offline downloads", NotificationManager.IMPORTANCE_LOW),
)
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setSmallIcon(io.narl.protonstream.R.drawable.ic_launcher_foreground)
.setContentTitle("Making episode available offline")
.setProgress(100, progress, progress == 0)
.setOngoing(true)
.build()
return ForegroundInfo(
stableNotificationId(id),
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
)
}
private fun error(message: String) = Data.Builder().putString(KEY_ERROR, message).build()
companion object {
const val KEY_SHARE_ID = "share_id"
const val KEY_VOLUME_ID = "volume_id"
const val KEY_LINK_ID = "link_id"
const val KEY_LABEL = "label"
const val KEY_ERROR = "error"
const val KEY_DOWNLOADED = "downloaded"
const val KEY_TOTAL = "total"
private const val MAX_RETRIES = 3
private const val CHANNEL_ID = "offline-downloads"
}
}
internal fun stableNotificationId(workId: UUID): Int =
(workId.hashCode() and Int.MAX_VALUE).coerceAtLeast(1)
@@ -0,0 +1,68 @@
package io.narl.protonstream.native
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import uniffi.pstr_android.AndroidSecretStore
/** Encrypts share fragments and passwords before they reach app preferences. */
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
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
val payload = Base64.decode(encoded, Base64.NO_WRAP)
require(payload.size > IV_SIZE) { "Invalid encrypted credential" }
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(
Cipher.DECRYPT_MODE,
encryptionKey(),
GCMParameterSpec(TAG_BITS, payload.copyOfRange(0, IV_SIZE)),
)
return cipher.doFinal(payload.copyOfRange(IV_SIZE, payload.size)).toString(Charsets.UTF_8)
}
override fun delete(key: String) {
check(preferences.edit().remove(key).commit())
}
private fun encryptionKey(): SecretKey {
(keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it }
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").run {
init(
KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.build(),
)
generateKey()
}
}
private companion object {
const val PREFERENCES = "proton_stream_secrets"
const val KEY_ALIAS = "proton-stream-share-secrets-v1"
const val TRANSFORMATION = "AES/GCM/NoPadding"
const val IV_SIZE = 12
const val TAG_BITS = 128
}
}
@@ -0,0 +1,40 @@
package io.narl.protonstream.native
import android.content.Context
import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import uniffi.pstr_android.AndroidEngine
import uniffi.pstr_android.AndroidPaths
/** Process-wide owner of the Rust engine; records and decrypted media remain app-private. */
object NativeRuntime {
private lateinit var appContext: Context
@Volatile private var instance: AndroidEngine? = null
fun initialize(context: Context) {
System.loadLibrary("pstr_android")
initTls(context)
appContext = context.applicationContext
}
/** Initializes Android's certificate verifier before Proton networking. */
private external fun initTls(context: Context)
suspend fun engine(): AndroidEngine = withContext(Dispatchers.IO) {
instance ?: synchronized(this@NativeRuntime) {
instance ?: createEngine().also { instance = it }
}
}
private fun createEngine(): AndroidEngine {
check(::appContext.isInitialized) { "NativeRuntime is not initialized" }
val config = File(appContext.noBackupFilesDir, "config")
val data = File(appContext.filesDir, "data")
val cache = File(appContext.cacheDir, "stream")
return AndroidEngine(
AndroidPaths(config.absolutePath, data.absolutePath, cache.absolutePath),
KeystoreSecretStore(appContext),
)
}
}
@@ -0,0 +1,273 @@
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.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.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.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) {
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() }
}
}
Box(Modifier.fillMaxSize()) {
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()
})
}
},
)
if (host is NativeMpvHost) PlayerControls(titleKey, episode, host, Modifier.align(Alignment.BottomCenter))
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, 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) {
Button(onClick = { host.setPaused(!state.paused) }) { Text(if (state.paused) "Play" else "Pause") }
Text("${formatTime(state.position)} / ${formatTime(state.duration)}", color = Color.White)
Button(onClick = { host.setMuted(!state.muted) }) { Text(if (state.muted) "Unmute" else "Mute") }
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { choosing = "audio" }) {
Text("Audio: ${tracks.firstOrNull { it.type == "audio" && it.selected }?.label ?: "default"}", maxLines = 1)
}
Button(onClick = { choosing = "sub" }) {
Text("Subtitles: ${tracks.firstOrNull { it.type == "sub" && it.selected }?.label ?: "off"}", maxLines = 1)
}
}
}
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)
}
@@ -0,0 +1,190 @@
package io.narl.protonstream.playback
import android.view.Surface
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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONArray
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
data class MpvPlaybackState(
val position: Double = 0.0,
val duration: Double = 0.0,
val volume: Double = 100.0,
val paused: Boolean = true,
val muted: Boolean = false,
val ended: Boolean = false,
)
data class MpvTrack(
val id: Long,
val type: String,
val language: String,
val title: String,
val selected: Boolean,
) {
val label: String get() = listOf(language, title).filter(String::isNotBlank)
.joinToString(" — ").ifBlank { "${type.replaceFirstChar(Char::uppercase)} $id" }
}
/** 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" } }
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 var poller: Job? = null
private var currentStreamHandle: Long? = null
var onPlaybackStarted: (() -> Unit)? = null
var onStateChanged: ((MpvPlaybackState) -> Unit)? = null
var onExplicitStop: (() -> Unit)? = null
val state: StateFlow<MpvPlaybackState> = mutableState.asStateFlow()
val tracks: StateFlow<List<MpvTrack>> = mutableTracks.asStateFlow()
val hasActivePlayback: Boolean get() = mutableState.value.duration > 0.0 && !mutableState.value.ended
override fun attachSurface(surface: Surface) {
withOpenHandle { nativeAttachSurface(it, surface) }
}
override fun detachSurface() {
withOpenHandle { nativeDetachSurface(it) }
}
override suspend fun play(
nativeHandle: ULong,
size: ULong,
startPosition: Double,
audioLanguage: String?,
subtitleLanguage: String?,
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()
}
}
} ?: false
check(loaded) {
"libmpv rejected the stream or no video surface was available"
}
}
withOpenHandle {
onPlaybackStarted?.invoke()
startPolling()
}
}
fun setPaused(paused: Boolean) { withOpenHandle { nativePause(it, paused) } }
fun seek(position: Double) { withOpenHandle { nativeSeek(it, position) } }
fun setVolume(volume: Double) { withOpenHandle { nativeVolume(it, volume) } }
fun setMuted(muted: Boolean) { withOpenHandle { nativeMute(it, muted) } }
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. */
fun stop() {
val callback = withOpenHandle {
nativeStop(it)
currentStreamHandle?.let(::pstrAndroidStreamRelease)
currentStreamHandle = 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 {
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)
mutableState.value = next
onStateChanged?.invoke(next)
}
if (tick++ % 4 == 0) refreshTracks()
delay(250)
}
}
}
private fun refreshTracks() {
runCatching {
val encoded = withOpenHandle(::nativeTracks) ?: return
val values = JSONArray(encoded)
List(values.length()) { index ->
values.getJSONObject(index).run {
MpvTrack(getLong("id"), getString("type"), getString("language"), getString("title"), getBoolean("selected"))
}
}
}.onSuccess { mutableTracks.value = it }
}
override fun close() {
scope.cancel()
lifecycle.withLock {
if (closed) return@withLock
closed = true
onPlaybackStarted = null
onStateChanged = null
onExplicitStop = null
nativeDestroy(nativeHandle)
currentStreamHandle?.let(::pstrAndroidStreamRelease)
currentStreamHandle = null
}
}
/** Serializes destruction against every JNI use of the raw Player pointer. */
private inline fun <T> withOpenHandle(block: (Long) -> T): T? = lifecycle.withLock {
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)
private external fun nativeLoad(handle: Long, stream: Long, start: Double, audio: String?, subtitle: String?, subtitles: Boolean): Boolean
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 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 pstrAndroidStreamRelease(handle: Long)
companion object {
init {
System.loadLibrary("pstr_android")
System.loadLibrary("pstr_mpv")
}
}
}
@@ -0,0 +1,174 @@
package io.narl.protonstream.playback
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.media.MediaMetadata
import android.media.session.MediaSession
import android.media.session.PlaybackState
import android.os.Binder
import android.os.IBinder
import io.narl.protonstream.MainActivity
import io.narl.protonstream.settings.SettingsStore
/** Owns libmpv so audio survives Activity recreation, rotation and PiP. */
class PlaybackService : Service() {
inner class PlaybackBinder : Binder() {
val host: NativeMpvHost get() = this@PlaybackService.host
}
private lateinit var host: NativeMpvHost
private lateinit var mediaSession: MediaSession
private lateinit var audioManager: AudioManager
private var audioFocus: AudioFocusRequest? = null
private var notifiedPaused: Boolean? = null
private var foregroundPlayback = false
private var sessionPlayback = false
private val binder = PlaybackBinder()
override fun onCreate() {
super.onCreate()
createChannel()
audioManager = getSystemService(AudioManager::class.java)
host = NativeMpvHost().apply {
onPlaybackStarted = {
requestAudioFocus()
sessionPlayback = true
if (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
}
mediaSession.isActive = true
}
onStateChanged = { updateSession(it) }
onExplicitStop = { retirePlayback() }
}
mediaSession = MediaSession(this, "proton-stream").apply {
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 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()
}
return START_NOT_STICKY
}
override fun onDestroy() {
audioFocus?.let(audioManager::abandonAudioFocusRequest)
mediaSession.release()
host.close()
super.onDestroy()
}
private fun requestAudioFocus() {
audioFocus?.let(audioManager::abandonAudioFocusRequest)
val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes(
AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MOVIE).build(),
)
.setOnAudioFocusChangeListener { focus ->
when (focus) {
AudioManager.AUDIOFOCUS_LOSS, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> host.setPaused(true)
AudioManager.AUDIOFOCUS_GAIN -> Unit
}
}.build()
audioFocus = request
audioManager.requestAudioFocus(request)
}
private fun updateSession(state: MpvPlaybackState) {
val status = when {
state.ended -> PlaybackState.STATE_STOPPED
state.paused -> PlaybackState.STATE_PAUSED
else -> PlaybackState.STATE_PLAYING
}
mediaSession.setPlaybackState(
PlaybackState.Builder()
.setActions(PlaybackState.ACTION_PLAY or PlaybackState.ACTION_PAUSE or PlaybackState.ACTION_SEEK_TO or PlaybackState.ACTION_STOP)
.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(),
)
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))
}
}
private fun retirePlayback() {
if (!sessionPlayback && !foregroundPlayback) return
sessionPlayback = false
notifiedPaused = null
mediaSession.isActive = false
audioFocus?.let(audioManager::abandonAudioFocusRequest)
audioFocus = null
if (foregroundPlayback) stopForeground(STOP_FOREGROUND_REMOVE)
foregroundPlayback = false
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,
)
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)
.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))
.build()
}
private fun createChannel() {
getSystemService(NotificationManager::class.java).createNotificationChannel(
NotificationChannel(CHANNEL_ID, "Playback", NotificationManager.IMPORTANCE_LOW),
)
}
companion object {
private const val CHANNEL_ID = "playback"
private const val NOTIFICATION_ID = 47
private const val ACTION_PLAY = "io.narl.protonstream.PLAY"
private const val ACTION_PAUSE = "io.narl.protonstream.PAUSE"
private const val ACTION_STOP = "io.narl.protonstream.STOP"
}
}
@@ -0,0 +1,21 @@
package io.narl.protonstream.settings
import android.content.Context
class SettingsStore(context: Context) {
private val preferences = context.getSharedPreferences(NAME, Context.MODE_PRIVATE)
var wifiOnly: Boolean
get() = preferences.getBoolean(KEY_WIFI_ONLY, true)
set(value) { preferences.edit().putBoolean(KEY_WIFI_ONLY, value).apply() }
var backgroundAudio: Boolean
get() = preferences.getBoolean(KEY_BACKGROUND_AUDIO, true)
set(value) { preferences.edit().putBoolean(KEY_BACKGROUND_AUDIO, value).apply() }
private companion object {
const val NAME = "proton_stream_settings"
const val KEY_WIFI_ONLY = "wifi_only"
const val KEY_BACKGROUND_AUDIO = "background_audio"
}
}
@@ -0,0 +1,225 @@
package io.narl.protonstream.ui
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.Observer
import androidx.work.WorkInfo
import androidx.work.WorkManager
import io.narl.protonstream.download.DownloadCoordinator
import io.narl.protonstream.download.DownloadStateStore
import io.narl.protonstream.download.RetainedDownload
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.withContext
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.MetadataProvider
import uniffi.pstr_android.MetadataSettingsRecord
data class AppUiState(
val loading: Boolean = true,
val refreshing: Boolean = false,
val query: String = "",
val titles: List<TitleRecord> = emptyList(),
val shares: List<ShareRecord> = emptyList(),
val offline: List<OfflineRecord> = emptyList(),
val metadataSettings: MetadataSettingsRecord = MetadataSettingsRecord(
enabled = false,
provider = MetadataProvider.ANI_LIST,
language = "en",
ready = true,
),
val message: String? = null,
)
@OptIn(FlowPreview::class)
class AppViewModel(context: Context, private val workManager: WorkManager) : ViewModel() {
private val appContext = context.applicationContext
private val mutableState = MutableStateFlow(AppUiState())
private val searchQuery = MutableStateFlow("")
val state: StateFlow<AppUiState> = mutableState.asStateFlow()
init {
reload()
viewModelScope.launch {
searchQuery.debounce(300).distinctUntilChanged().collect { reloadLibrary(it) }
}
viewModelScope.launch {
workManager.workInfosByTagFlow(DownloadCoordinator.TAG)
.map { work -> work.filter { it.state.isFinished }.map { it.id }.toSet() }
.distinctUntilChanged()
.drop(1)
.collect { reload() }
}
}
fun search(query: String) {
mutableState.update { it.copy(query = query) }
searchQuery.value = query
}
fun refresh() {
viewModelScope.launch {
mutableState.update { it.copy(refreshing = true, message = null) }
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(null) } }
.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 {
withContext(Dispatchers.IO) {
val engine = NativeRuntime.engine()
engine.addShare(name, url, password?.takeIf(String::isNotBlank))
engine.crawl(null)
}
}.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
reload()
}
}
fun removeShare(id: String) {
viewModelScope.launch {
runCatching {
withContext(Dispatchers.IO) {
// Wait for WorkManager cancellation before Rust removes
// catalog/files. Rust also rejects any late publication.
workManager.cancelAllWorkByTag(DownloadCoordinator.shareTag(id)).result.get()
val downloads = DownloadStateStore(appContext)
val engine = NativeRuntime.engine()
downloads.records().filter { it.shareId == id }.forEach {
engine.removeOfflineEpisode(it.shareId, it.linkId)
}
downloads.removeShare(id)
engine.removeShare(id)
}
}
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
reload()
}
}
fun dismissMessage() = mutableState.update { it.copy(message = null) }
fun reportError(error: Throwable) {
mutableState.update { it.copy(message = error.message ?: "Unexpected error") }
}
fun saveMetadataSettings(enabled: Boolean, provider: MetadataProvider, language: String, apiKey: String) {
viewModelScope.launch {
runCatching {
withContext(Dispatchers.IO) {
val engine = NativeRuntime.engine()
if (provider == MetadataProvider.TMDB && apiKey.isNotBlank()) {
engine.setMetadataApiKey(provider, apiKey)
}
engine.setMetadataSettings(MetadataSettingsRecord(enabled, provider, language, true))
if (enabled) engine.matchTitles(false)
}
}.onFailure(::reportError)
reload()
}
}
fun reloadAfterMetadataChange() = reload()
fun removeOffline(file: OfflineRecord) {
viewModelScope.launch {
runCatching {
withContext(Dispatchers.IO) { NativeRuntime.engine().removeOfflineEpisode(file.shareId, file.linkId) }
}.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
reload()
}
}
fun pauseDownload(download: RetainedDownload) = DownloadCoordinator.pause(appContext, download)
fun resumeDownload(download: RetainedDownload) = DownloadCoordinator.resume(appContext, download)
fun deletePartial(download: RetainedDownload) {
viewModelScope.launch {
DownloadCoordinator.cancel(appContext, download)
runCatching {
withContext(Dispatchers.IO) {
workManager.cancelUniqueWork(
DownloadCoordinator.workName(download.shareId, download.linkId),
).result.get()
NativeRuntime.engine().removeOfflineEpisode(download.shareId, download.linkId)
DownloadStateStore(appContext).remove(download.shareId, download.linkId)
}
}.onFailure(::reportError)
reload()
}
}
private fun reload() {
viewModelScope.launch {
runCatching {
withContext(Dispatchers.IO) {
val engine = NativeRuntime.engine()
Reloaded(
engine.shares(),
engine.library(mutableState.value.query.takeIf(String::isNotBlank)),
engine.offlineFiles(),
engine.metadataSettings(),
)
}
}.onSuccess { result ->
mutableState.update { it.copy(
loading = false,
shares = result.shares,
titles = result.titles,
offline = result.offline,
metadataSettings = result.metadataSettings,
) }
}
.onFailure { error ->
mutableState.update { it.copy(loading = false, message = error.message) }
}
}
}
private suspend fun reloadLibrary(query: String) {
runCatching {
withContext(Dispatchers.IO) { NativeRuntime.engine().library(query.takeIf(String::isNotBlank)) }
}.onSuccess { titles -> mutableState.update { it.copy(titles = titles) } }
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
}
class Factory(private val context: Context, private val workManager: WorkManager) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = AppViewModel(context, workManager) as T
}
}
private data class Reloaded(
val shares: List<ShareRecord>,
val titles: List<TitleRecord>,
val offline: List<OfflineRecord>,
val metadataSettings: MetadataSettingsRecord,
)
private fun WorkManager.workInfosByTagFlow(tag: String) = callbackFlow {
val work = getWorkInfosByTagLiveData(tag)
val observer = Observer<List<WorkInfo>> { trySend(it).isSuccess }
work.observeForever(observer)
awaitClose { work.removeObserver(observer) }
}
@@ -0,0 +1,748 @@
package io.narl.protonstream.ui
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
import androidx.compose.runtime.Composable
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.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.SecureFlagPolicy
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.work.WorkManager
import io.narl.protonstream.settings.SettingsStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import io.narl.protonstream.download.DownloadCoordinator
import io.narl.protonstream.download.DownloadStateStore
import io.narl.protonstream.download.RetainedDownload
import io.narl.protonstream.native.NativeRuntime
import uniffi.pstr_android.ShareRecord
import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.TrackPreferencesRecord
import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.MatchRecord
import uniffi.pstr_android.MetadataProvider
import io.narl.protonstream.playback.NativeMpvHost
import io.narl.protonstream.playback.LibmpvPlayerSurface
private enum class Destination(val label: String, val glyph: String) {
Library("Library", "▦"),
Shares("Shares", "⇄"),
Downloads("Downloads", "⇩"),
Settings("Settings", "⚙"),
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProtonStreamApp(
playerHost: NativeMpvHost? = null,
model: AppViewModel = viewModel(
factory = AppViewModel.Factory(
LocalContext.current,
WorkManager.getInstance(LocalContext.current),
),
),
) {
val state by model.state.collectAsState()
val snackbars = remember { SnackbarHostState() }
var destination by remember { mutableStateOf(Destination.Library) }
var selectedTitle by remember { mutableStateOf<TitleRecord?>(null) }
LaunchedEffect(state.titles, selectedTitle?.key) {
selectedTitle?.let { selected ->
selectedTitle = state.titles.firstOrNull { it.key == selected.key } ?: selected
}
}
LaunchedEffect(state.message) {
state.message?.let {
snackbars.showSnackbar(it)
model.dismissMessage()
}
}
NavigationSuiteScaffold(
navigationSuiteItems = {
Destination.entries.forEach { item ->
item(
selected = destination == item,
onClick = { destination = item; selectedTitle = null },
icon = { Text(item.glyph) },
label = { Text(item.label) },
)
}
},
) {
Scaffold(
snackbarHost = { SnackbarHost(snackbars) },
topBar = {
TopAppBar(
title = { Text(selectedTitle?.name ?: "proton-stream") },
actions = {
if (destination == Destination.Library) {
FilledTonalButton(onClick = model::refresh, enabled = !state.refreshing) {
Text(if (state.refreshing) "Refreshing…" else "Refresh")
}
Spacer(Modifier.width(12.dp))
}
},
)
},
) { padding ->
AnimatedContent(destination, label = "primary navigation") { target ->
when (target) {
Destination.Library -> if (selectedTitle == null) {
LibraryScreen(state, model::search, { selectedTitle = it }, padding)
} else {
TitleScreen(
selectedTitle!!,
playerHost,
{ selectedTitle = null },
model::reportError,
model::reloadAfterMetadataChange,
padding,
)
}
Destination.Shares -> SharesScreen(state.shares, model::addShare, model::removeShare, padding)
Destination.Downloads -> DownloadsScreen(
state,
model::removeOffline,
model::pauseDownload,
model::resumeDownload,
model::deletePartial,
padding,
)
Destination.Settings -> SettingsScreen(state, model::saveMetadataSettings, padding)
}
}
}
}
}
@Composable
private fun LibraryScreen(
state: AppUiState,
onSearch: (String) -> Unit,
onTitle: (TitleRecord) -> Unit,
padding: PaddingValues,
) {
Column(Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp)) {
OutlinedTextField(
value = state.query,
onValueChange = onSearch,
modifier = Modifier.fillMaxWidth(),
singleLine = true,
placeholder = { Text("Search library") },
)
Spacer(Modifier.height(16.dp))
if (state.loading) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
} else if (state.titles.isEmpty()) {
EmptyState("Your library is empty", "Add a Proton Drive public link under Shares, then refresh.")
} else {
LazyVerticalGrid(
columns = GridCells.Adaptive(180.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(bottom = 24.dp),
) {
items(state.titles, key = { it.key }) { title ->
Card(Modifier.fillMaxWidth().clickable { onTitle(title) }) {
RemoteArtwork(
title.backdropUrl ?: title.posterUrl,
title.canonicalName ?: title.name,
Modifier.fillMaxWidth().height(210.dp),
)
Column(Modifier.padding(12.dp)) {
Text(title.name, fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis)
Text(
listOfNotNull(title.year?.toString(), "${title.episodeCount} episodes").joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}
}
}
}
@Composable
private fun TitleScreen(
title: TitleRecord,
playerHost: NativeMpvHost?,
onBack: () -> Unit,
onPreferenceError: (Throwable) -> Unit,
onMetadataChanged: () -> Unit,
padding: PaddingValues,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var audioLanguage by remember(title.key) { mutableStateOf("") }
var subtitleLanguage by remember(title.key) { mutableStateOf("") }
var subtitlesEnabled by remember(title.key) { mutableStateOf(false) }
var playing by remember(title.key) { mutableStateOf<EpisodeRecord?>(null) }
var showMatch by remember(title.key) { mutableStateOf(false) }
LaunchedEffect(title.key) {
runCatching {
withContext(Dispatchers.IO) {
NativeRuntime.engine().titleTrackPreferences(title.key)
}
}.onSuccess { preferences ->
audioLanguage = preferences.audioLanguage.orEmpty()
subtitleLanguage = preferences.subtitleLanguage.orEmpty()
subtitlesEnabled = preferences.subtitles
}.onFailure(onPreferenceError)
}
if (playing != null) {
Box(Modifier.fillMaxSize().padding(padding).background(Color.Black)) {
LibmpvPlayerSurface(title.key, playing!!, playerHost)
FilledTonalButton(onClick = { playerHost?.stop(); playing = null }, Modifier.padding(16.dp)) { Text("Back") }
}
return
}
LazyColumn(
modifier = Modifier.fillMaxSize().padding(padding),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item {
FilledTonalButton(onClick = onBack) { Text("Back to library") }
RemoteArtwork(
title.backdropUrl ?: title.posterUrl,
title.canonicalName ?: title.name,
Modifier.fillMaxWidth().aspectRatio(16f / 9f).padding(top = 12.dp),
)
Text(title.canonicalName ?: title.name, style = MaterialTheme.typography.headlineMedium, modifier = Modifier.padding(top = 12.dp))
title.originalName?.takeIf { it != title.canonicalName }?.let {
Text(it, style = MaterialTheme.typography.bodyMedium)
}
Text(
listOfNotNull(
(title.metadataYear ?: title.year)?.toString(),
title.rating?.let { "%.1f/10".format(it) },
title.genres.takeIf { it.isNotEmpty() }?.joinToString(" · "),
).joinToString(" • "),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 6.dp),
)
title.overview?.let { Text(it, modifier = Modifier.padding(top = 10.dp)) }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 12.dp)) {
Button(onClick = { DownloadCoordinator.enqueue(context, title.seasons.flatMap { it.episodes }) }) {
Text("Download show")
}
FilledTonalButton(onClick = { showMatch = true }) { Text("Change match") }
}
Text("${title.watchedCount} of ${title.episodeCount} watched", Modifier.padding(vertical = 12.dp))
Text("Preferred tracks", style = MaterialTheme.typography.titleMedium)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
audioLanguage,
{ audioLanguage = it },
label = { Text("Audio language") },
modifier = Modifier.weight(1f),
singleLine = true,
)
OutlinedTextField(
subtitleLanguage,
{ subtitleLanguage = it },
label = { Text("Subtitle language") },
modifier = Modifier.weight(1f),
singleLine = true,
)
}
SettingToggle("Enable subtitles", subtitlesEnabled) { subtitlesEnabled = it }
FilledTonalButton(onClick = {
scope.launch {
runCatching {
withContext(Dispatchers.IO) {
NativeRuntime.engine().setTitleTrackPreferences(
title.key,
TrackPreferencesRecord(
audioLanguage.takeIf(String::isNotBlank),
subtitleLanguage.takeIf(String::isNotBlank),
subtitlesEnabled,
),
)
}
}.onFailure(onPreferenceError)
}
}) { Text("Save track preferences") }
}
title.seasons.forEach { season ->
item {
Row(
Modifier.fillMaxWidth().padding(top = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(season.label, style = MaterialTheme.typography.titleLarge)
FilledTonalButton(onClick = { DownloadCoordinator.enqueue(context, season.episodes) }) {
Text("Download season")
}
}
}
items(season.episodes, key = { it.linkId }) { episode ->
Card(Modifier.fillMaxWidth()) {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Button(onClick = { playing = episode }, enabled = playerHost != null) {
Text(if (playerHost == null) "Player loading…" else "Play")
}
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
Text(episode.label, fontWeight = FontWeight.SemiBold)
Text(episode.detail, style = MaterialTheme.typography.bodySmall, maxLines = 1)
}
FilledTonalButton(
onClick = { DownloadCoordinator.enqueue(context, episode) },
enabled = !episode.offline,
) { Text(if (episode.offline) "Offline" else "Download") }
}
}
}
}
}
if (showMatch) {
ChangeMatchDialog(
title = title,
onDismiss = { showMatch = false },
onChanged = { showMatch = false; onMetadataChanged() },
onError = onPreferenceError,
)
}
}
@Composable
private fun ChangeMatchDialog(
title: TitleRecord,
onDismiss: () -> Unit,
onChanged: () -> Unit,
onError: (Throwable) -> Unit,
) {
var term by remember(title.key) { mutableStateOf(title.canonicalName ?: title.name) }
var searching by remember { mutableStateOf(false) }
var options by remember { mutableStateOf<List<MatchRecord>>(emptyList()) }
val scope = rememberCoroutineScope()
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Change match") },
text = {
Column {
Text("Search the configured metadata provider. Nothing is stored until you choose an entry.")
OutlinedTextField(term, { term = it }, Modifier.fillMaxWidth().padding(vertical = 8.dp), singleLine = true)
Button(enabled = term.isNotBlank() && !searching, onClick = {
searching = true
scope.launch {
runCatching {
withContext(Dispatchers.IO) { NativeRuntime.engine().searchMatches(title.key, term) }
}.onSuccess { options = it }.onFailure(onError)
searching = false
}
}) { Text(if (searching) "Searching…" else "Search") }
LazyColumn(Modifier.fillMaxWidth().heightIn(max = 300.dp)) {
items(options, key = { "${it.provider}:${it.remoteId}" }) { option ->
OutlinedButton(
onClick = {
scope.launch {
runCatching {
withContext(Dispatchers.IO) { NativeRuntime.engine().chooseMatch(title.key, option) }
}.onSuccess { onChanged() }.onFailure(onError)
}
},
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
) {
Column(Modifier.fillMaxWidth()) {
Text(option.name, fontWeight = FontWeight.SemiBold)
Text(listOfNotNull(option.year?.toString(), option.originalName).joinToString(" · "))
}
}
}
}
}
},
confirmButton = {
TextButton(onClick = {
scope.launch {
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().forgetMatch(title.key) } }
.onSuccess { onChanged() }.onFailure(onError)
}
}) { Text("Forget match") }
},
dismissButton = { FilledTonalButton(onClick = onDismiss) { Text("Close") } },
)
}
@Composable
private fun SharesScreen(
shares: List<ShareRecord>,
onAdd: (String, String, String?) -> Unit,
onRemove: (String) -> Unit,
padding: PaddingValues,
) {
var showAdd by remember { mutableStateOf(false) }
Column(Modifier.fillMaxSize().padding(padding).padding(16.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Text("Proton Drive public links", style = MaterialTheme.typography.titleLarge)
Button(onClick = { showAdd = true }) { Text("Add share") }
}
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 16.dp)) {
items(shares, key = { it.id }) { share ->
Card(Modifier.fillMaxWidth()) {
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(share.name, fontWeight = FontWeight.SemiBold)
Text(if (share.hasCustomPassword) "Custom password stored securely" else "Public link")
}
FilledTonalButton(onClick = { onRemove(share.id) }) { Text("Remove") }
}
}
}
}
}
if (showAdd) AddShareDialog(onDismiss = { showAdd = false }, onAdd = onAdd)
}
@Composable
private fun AddShareDialog(onDismiss: () -> Unit, onAdd: (String, String, String?) -> Unit) {
var name by remember { mutableStateOf("") }
var url by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
title = { Text("Add Proton Drive share") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(name, { name = it }, label = { Text("Library name") }, singleLine = true)
OutlinedTextField(
url,
{ url = it },
label = { Text("Public share URL") },
singleLine = true,
// The URL fragment is a secret. Password input disables
// IME learning/suggestions while normal long-press paste
// remains available.
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
)
OutlinedTextField(
password,
{ password = it },
label = { Text("Custom password (optional)") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
)
}
},
confirmButton = {
Button(
onClick = { onAdd(name.trim(), url.trim(), password); onDismiss() },
enabled = name.isNotBlank() && url.isNotBlank(),
) { Text("Add") }
},
dismissButton = { FilledTonalButton(onClick = onDismiss) { Text("Cancel") } },
)
}
@Composable
private fun DownloadsScreen(
state: AppUiState,
onRemove: (uniffi.pstr_android.OfflineRecord) -> Unit,
onPause: (RetainedDownload) -> Unit,
onResume: (RetainedDownload) -> Unit,
onDeletePartial: (RetainedDownload) -> Unit,
padding: PaddingValues,
) {
val context = LocalContext.current
val work = WorkManager.getInstance(context).getWorkInfosByTagLiveData(DownloadCoordinator.TAG)
val downloads by work.observeAsState(emptyList())
// Reading retained metadata on every WorkInfo transition also hydrates
// paused/failed/cancelled entries after WorkManager history is pruned.
val retained = remember(downloads) { DownloadStateStore(context).records() }
Column(Modifier.fillMaxSize().padding(padding).padding(16.dp)) {
Text("Offline downloads", style = MaterialTheme.typography.titleLarge)
Spacer(Modifier.height(16.dp))
if (retained.isEmpty() && state.offline.isEmpty()) {
EmptyState("No downloads", "Episodes, seasons, and shows saved offline appear here.")
}
state.offline.forEach { file ->
val episode = file.episode
Card(Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(episode?.label ?: file.linkId, fontWeight = FontWeight.SemiBold)
Text(formatBytes(file.size))
}
FilledTonalButton(onClick = { onRemove(file) }) { Text("Make online only") }
}
}
}
retained.forEach { download ->
val progress = if (download.total > 0L) download.downloaded.toFloat() / download.total else 0f
Card(Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
Column(Modifier.padding(16.dp)) {
Text(download.label, fontWeight = FontWeight.SemiBold)
Text(download.status.replaceFirstChar { it.uppercase() })
if (download.total > 0L) {
LinearProgressIndicator(
progress = { progress },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
Text("${formatBytes(download.downloaded.toULong())} of ${formatBytes(download.total.toULong())}", style = MaterialTheme.typography.bodySmall)
}
download.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(top = 8.dp),
) {
if (download.status == RetainedDownload.STATUS_RUNNING ||
download.status == RetainedDownload.STATUS_QUEUED
) {
FilledTonalButton(onClick = { onPause(download) }) { Text("Pause") }
} else {
FilledTonalButton(onClick = { onResume(download) }) { Text("Resume") }
}
TextButton(onClick = { onDeletePartial(download) }) { Text("Delete partial") }
}
}
}
}
}
}
@Composable
private fun SettingsScreen(
state: AppUiState,
onSaveMetadata: (Boolean, MetadataProvider, String, String) -> Unit,
padding: PaddingValues,
) {
val context = LocalContext.current
val settings = remember { SettingsStore(context) }
var wifiOnly by remember { mutableStateOf(settings.wifiOnly) }
var backgroundAudio by remember { mutableStateOf(settings.backgroundAudio) }
var showMetadata by remember { mutableStateOf(false) }
var legalDocument by remember { mutableStateOf<LegalDocument?>(null) }
Column(Modifier.fillMaxSize().padding(padding).padding(16.dp)) {
Text("Settings", style = MaterialTheme.typography.titleLarge)
SettingToggle("Download on Wi-Fi only", wifiOnly) { wifiOnly = it; settings.wifiOnly = it }
HorizontalDivider()
SettingToggle("Continue audio in the background", backgroundAudio) {
backgroundAudio = it
settings.backgroundAudio = it
}
Text(
"When disabled, leaving playback stops the player instead of keeping a media notification active.",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(bottom = 14.dp),
)
HorizontalDivider()
Text("Metadata enrichment", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 20.dp))
Text(
if (state.metadataSettings.enabled) "On · ${state.metadataSettings.provider.displayName()}"
else "Off (privacy default)",
)
Text(
"Enabling this sends library title names to the selected third-party provider over HTTPS.",
style = MaterialTheme.typography.bodySmall,
)
FilledTonalButton(onClick = { showMetadata = true }, modifier = Modifier.padding(vertical = 10.dp)) {
Text("Configure metadata")
}
HorizontalDivider()
Text("Storage", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 20.dp))
Text("Offline media is encrypted at rest by Android and kept in app-private storage.")
Text("proton-stream Android · GPL-3.0-or-later", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(top = 24.dp))
Text(
"This program comes with absolutely no warranty. You may redistribute it under the GNU GPL.",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 8.dp),
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 12.dp)) {
FilledTonalButton(onClick = {
legalDocument = LegalDocument("GNU GPL v3", "licenses/GPL-3.0.txt")
}) { Text("View license") }
FilledTonalButton(onClick = {
legalDocument = LegalDocument("Third-party notices", "licenses/THIRD_PARTY_NOTICES.md")
}) { Text("View notices") }
}
}
legalDocument?.let { document ->
LegalDocumentDialog(document, onDismiss = { legalDocument = null })
}
if (showMetadata) {
MetadataSettingsDialog(
current = state.metadataSettings,
onDismiss = { showMetadata = false },
onSave = { enabled, provider, language, key ->
onSaveMetadata(enabled, provider, language, key)
showMetadata = false
},
)
}
}
@Composable
private fun MetadataSettingsDialog(
current: uniffi.pstr_android.MetadataSettingsRecord,
onDismiss: () -> Unit,
onSave: (Boolean, MetadataProvider, String, String) -> Unit,
) {
var enabled by remember { mutableStateOf(current.enabled) }
var provider by remember { mutableStateOf(current.provider) }
var language by remember { mutableStateOf(current.language) }
var apiKey by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
title = { Text("Metadata enrichment") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Off by default: enabling sends the titles in your library to a third party, associated with your IP address and subject to their privacy policy.")
SettingToggle("Enable enrichment", enabled) { enabled = it }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
MetadataProvider.entries.forEach { option ->
if (option == provider) Button(onClick = { provider = option }) { Text(option.displayName()) }
else OutlinedButton(onClick = { provider = option }) { Text(option.displayName()) }
}
}
Text(
if (provider == MetadataProvider.ANI_LIST) "Anime; no account or API key required."
else "Film and television; requires a free TMDB API key.",
style = MaterialTheme.typography.bodySmall,
)
if (provider == MetadataProvider.TMDB) {
OutlinedTextField(language, { language = it }, label = { Text("Language") }, singleLine = true)
OutlinedTextField(
apiKey,
{ apiKey = it },
label = { Text(if (current.ready) "TMDB API key (leave blank to keep)" else "TMDB API key") },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
singleLine = true,
)
}
}
},
confirmButton = {
Button(
enabled = !enabled || provider != MetadataProvider.TMDB || current.ready || apiKey.isNotBlank(),
onClick = { onSave(enabled, provider, language.ifBlank { "en" }, apiKey) },
) { Text("Save") }
},
dismissButton = { FilledTonalButton(onClick = onDismiss) { Text("Cancel") } },
)
}
private fun MetadataProvider.displayName(): String = when (this) {
MetadataProvider.ANI_LIST -> "AniList"
MetadataProvider.TMDB -> "TMDB"
}
private data class LegalDocument(val title: String, val assetPath: String)
@Composable
private fun LegalDocumentDialog(document: LegalDocument, onDismiss: () -> Unit) {
val context = LocalContext.current
var contents by remember(document.assetPath) { mutableStateOf("Loading…") }
LaunchedEffect(document.assetPath) {
contents = runCatching {
withContext(Dispatchers.IO) {
context.assets.open(document.assetPath).bufferedReader().use { it.readText() }
}
}.getOrElse { error -> "Unable to load this document: ${error.message}" }
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(document.title) },
text = {
LazyColumn(Modifier.fillMaxWidth().heightIn(max = 520.dp)) {
item { Text(contents, style = MaterialTheme.typography.bodySmall) }
}
},
confirmButton = { Button(onClick = onDismiss) { Text("Close") } },
)
}
@Composable
private fun SettingToggle(label: String, checked: Boolean, onChecked: (Boolean) -> Unit) {
Row(Modifier.fillMaxWidth().padding(vertical = 14.dp), verticalAlignment = Alignment.CenterVertically) {
Text(label, Modifier.weight(1f))
Switch(checked = checked, onCheckedChange = onChecked)
}
}
@Composable
private fun EmptyState(title: String, body: String) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(title, style = MaterialTheme.typography.titleMedium)
Text(body, style = MaterialTheme.typography.bodyMedium)
}
}
}
private fun formatBytes(bytes: ULong): String {
val value = bytes.toDouble()
return when {
value >= 1024 * 1024 * 1024 -> "%.1f GiB".format(value / (1024 * 1024 * 1024))
value >= 1024 * 1024 -> "%.1f MiB".format(value / (1024 * 1024))
else -> "${bytes} bytes"
}
}
@@ -0,0 +1,90 @@
package io.narl.protonstream.ui
import android.graphics.BitmapFactory
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.HttpURLConnection
import java.net.URI
import java.io.ByteArrayOutputStream
import java.io.InputStream
import java.security.MessageDigest
/** HTTPS-only artwork loader backed by the app-private cache directory. */
@Composable
internal fun RemoteArtwork(url: String?, description: String, modifier: Modifier = Modifier) {
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()
}
}
}
Box(modifier.background(MaterialTheme.colorScheme.surfaceVariant), contentAlignment = Alignment.Center) {
image?.let {
Image(it, description, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
} ?: Text("▶", style = MaterialTheme.typography.displaySmall)
}
}
private const val MAX_ART_BYTES = 12 * 1024 * 1024
/** Reads at most [MAX_ART_BYTES] plus one byte without requiring API 33's readNBytes. */
private fun readBounded(input: InputStream): ByteArray {
val output = ByteArrayOutputStream(MAX_ART_BYTES.coerceAtMost(64 * 1024))
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var total = 0
while (total <= MAX_ART_BYTES) {
val requested = minOf(buffer.size, MAX_ART_BYTES + 1 - total)
val count = input.read(buffer, 0, requested)
if (count < 0) break
if (count == 0) continue
output.write(buffer, 0, count)
total += count
}
return output.toByteArray()
}
@@ -0,0 +1,24 @@
package io.narl.protonstream.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
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),
)
@Composable
fun ProtonStreamTheme(content: @Composable () -> Unit) {
MaterialTheme(colorScheme = ProtonColors, content = content)
}