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