Add localized Android stream controls and player screen

This commit is contained in:
evgeny committed 2026-10-12 02:06:27 +02:00
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
})
}