Add localized Android stream controls and player screen
This commit is contained in:
1 parent
ee851b4aa6
commit
8502d6f2d7
3 files changed
+183
No files matched your search
@@ -0,0 +1,153 @@
|
||||
package com.utun.chat.ui.screens
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.SurfaceTexture
|
||||
import android.hardware.camera2.CameraCharacteristics
|
||||
import android.hardware.camera2.CameraManager
|
||||
import android.view.Surface
|
||||
import android.view.TextureView
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.utun.chat.R
|
||||
import com.utun.chat.data.H264AnnexB
|
||||
import com.utun.chat.data.LogManager
|
||||
import com.utun.chat.data.NativeLib
|
||||
import com.utun.chat.data.StreamController
|
||||
import com.utun.chat.data.StreamPhase
|
||||
import com.utun.chat.data.StreamPlayerEngine
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
private data class LiveSource(val id: Long, val name: String, val epoch: String)
|
||||
|
||||
/** Выход с экрана закрывает только reader; foreground publisher останавливает явная кнопка/notification. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun StreamScreen(channelId: String, channelName: String, onBack: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val group = remember(channelId) { channelId.toULongOrNull()?.toLong() ?: 0L }
|
||||
val state by StreamController.state.collectAsState()
|
||||
val local = state?.takeIf { it.group == group }
|
||||
var sources by remember(group) { mutableStateOf<List<LiveSource>>(emptyList()) }
|
||||
var selected by remember(group) { mutableStateOf<LiveSource?>(null) }
|
||||
var audio by remember { mutableStateOf(true) }
|
||||
var camera by remember { mutableStateOf("") }
|
||||
val cameras = remember {
|
||||
val manager = context.getSystemService(CameraManager::class.java)
|
||||
try { manager.cameraIdList.map { id -> id to (manager.getCameraCharacteristics(id).get(CameraCharacteristics.LENS_FACING)
|
||||
== CameraCharacteristics.LENS_FACING_FRONT) } }
|
||||
catch (e: Exception) { LogManager.addLog("ERROR", "stream", "camera enumeration: ${e.message}"); emptyList() }
|
||||
}
|
||||
LaunchedEffect(cameras) { if (camera.isEmpty()) camera = cameras.firstOrNull { !it.second }?.first ?: cameras.firstOrNull()?.first.orEmpty() }
|
||||
var permissionVersion by remember { mutableStateOf(0) }
|
||||
val permissions = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissionVersion++ }
|
||||
val cameraGranted = permissionVersion.let { ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED }
|
||||
val micGranted = permissionVersion.let { ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED }
|
||||
|
||||
LaunchedEffect(group) {
|
||||
while (true) {
|
||||
if (NativeLib.isReady()) sources = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val json = JSONArray(NativeLib.getChannelMembers(channelId))
|
||||
(0 until json.length()).mapNotNull { i ->
|
||||
val member = json.getJSONObject(i)
|
||||
val profile = JSONObject(member.optString("ui").ifEmpty { "{}" })
|
||||
val source = profile.optString("stream_source")
|
||||
val fields = source.split(':')
|
||||
if (fields.size != 4 || fields[0].length != 16 || fields[0].toULongOrNull(16) == null ||
|
||||
member.optInt("dl") != 0 || member.optInt("on") == 0) null
|
||||
else LiveSource(member.getLong("ni"), member.optString("n"), fields[0])
|
||||
}
|
||||
} catch (e: Exception) { LogManager.addLog("WARN", "stream", "stream list: ${e.message}"); emptyList() }
|
||||
} else sources = emptyList()
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(local?.id, local?.phase) {
|
||||
if (local?.phase == StreamPhase.LIVE) selected = LiveSource(NativeLib.getMyNodeId(), channelName, local.id.toString())
|
||||
}
|
||||
|
||||
Scaffold(topBar = {
|
||||
TopAppBar(title = { Text(stringResource(R.string.stream_title)) }, navigationIcon = {
|
||||
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back)) }
|
||||
})
|
||||
}) { padding ->
|
||||
Column(Modifier.fillMaxSize().padding(padding).padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(channelName, style = MaterialTheme.typography.titleMedium)
|
||||
if (local?.ongoing == true) {
|
||||
Text(stringResource(when (local.phase) {
|
||||
StreamPhase.LIVE -> R.string.stream_live
|
||||
StreamPhase.STOPPING -> R.string.stream_stopping
|
||||
else -> R.string.stream_connecting
|
||||
}))
|
||||
Button(onClick = StreamController::stop, enabled = local.phase != StreamPhase.STOPPING) { Text(stringResource(R.string.stream_stop)) }
|
||||
} else {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
cameras.forEach { (id, front) -> FilterChip(selected = camera == id, onClick = { camera = id },
|
||||
label = { Text(stringResource(if (front) R.string.stream_front_camera else R.string.stream_back_camera) + " $id") }) }
|
||||
}
|
||||
Row { Checkbox(checked = audio, onCheckedChange = { audio = it }); Text(stringResource(R.string.microphone), Modifier.padding(top = 12.dp)) }
|
||||
Button(onClick = {
|
||||
if (!cameraGranted || (audio && !micGranted)) permissions.launch(
|
||||
listOf(Manifest.permission.CAMERA).plus(if (audio) listOf(Manifest.permission.RECORD_AUDIO) else emptyList()).toTypedArray())
|
||||
else {
|
||||
val degrees = when ((context as? android.app.Activity)?.windowManager?.defaultDisplay?.rotation) {
|
||||
Surface.ROTATION_90 -> 90; Surface.ROTATION_180 -> 180; Surface.ROTATION_270 -> 270; else -> 0
|
||||
}
|
||||
StreamController.start(group, camera, audio, degrees)
|
||||
}
|
||||
}, enabled = camera.isNotEmpty() && group != 0L && state?.ongoing != true) { Text(stringResource(R.string.stream_start)) }
|
||||
}
|
||||
local?.error?.let { Text(stringResource(it), color = MaterialTheme.colorScheme.error) }
|
||||
if (sources.isEmpty() && local?.ongoing != true) Text(stringResource(R.string.stream_none))
|
||||
sources.forEach { source ->
|
||||
TextButton(onClick = { selected = source }) { Text(stringResource(R.string.stream_watch, source.name.ifEmpty { source.id.toULong().toString() })) }
|
||||
}
|
||||
selected?.let { source ->
|
||||
key(group, source.id, source.epoch) { LiveStreamView(group, source.id) }
|
||||
TextButton(onClick = { selected = null }) { Text(stringResource(R.string.stream_close_view)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LiveStreamView(group: Long, source: Long) {
|
||||
val engine = remember(group, source) { StreamPlayerEngine(group, source) }
|
||||
val aspect by engine.aspect.collectAsState()
|
||||
val status by engine.status.collectAsState()
|
||||
var surface by remember { mutableStateOf<Surface?>(null) }
|
||||
DisposableEffect(engine) { onDispose { engine.stop() } }
|
||||
Text(stringResource(status))
|
||||
AndroidView(factory = { context ->
|
||||
TextureView(context).apply {
|
||||
surfaceTextureListener = object : TextureView.SurfaceTextureListener {
|
||||
override fun onSurfaceTextureAvailable(texture: SurfaceTexture, width: Int, height: Int) {
|
||||
surface = Surface(texture); engine.start(surface!!)
|
||||
}
|
||||
override fun onSurfaceTextureSizeChanged(texture: SurfaceTexture, width: Int, height: Int) {}
|
||||
override fun onSurfaceTextureDestroyed(texture: SurfaceTexture): Boolean {
|
||||
engine.stop(); surface?.release(); surface = null; return true
|
||||
}
|
||||
override fun onSurfaceTextureUpdated(texture: SurfaceTexture) {}
|
||||
}
|
||||
}
|
||||
}, modifier = Modifier.fillMaxWidth().aspectRatio(aspect), onRelease = { view ->
|
||||
engine.stop(); view.surfaceTextureListener = null; surface?.release(); surface = null
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user