diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamScreen.kt new file mode 100644 index 00000000..f37f8ab8 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamScreen.kt @@ -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>(emptyList()) } + var selected by remember(group) { mutableStateOf(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(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 + }) +} diff --git a/tools/chatgui-android/app/src/main/res/values-ru/strings.xml b/tools/chatgui-android/app/src/main/res/values-ru/strings.xml index 7b240b26..e5d56f61 100644 --- a/tools/chatgui-android/app/src/main/res/values-ru/strings.xml +++ b/tools/chatgui-android/app/src/main/res/values-ru/strings.xml @@ -1,5 +1,20 @@ + Трансляции + Начать трансляцию + Остановить трансляцию + Прямая трансляция + Подключение… + Завершение трансляции… + Трансляция завершена + Не удалось запустить или продолжить трансляцию + Ожидание ключевого кадра… + В этом канале нет активных трансляций + Смотреть: %1$s + Закрыть просмотр + Фронтальная камера + Задняя камера + Остановите активный звонок, рацию, запись или трансляцию перед новым захватом "Для звонков и PTT разрешите доступ к микрофону" "Не удалось запросить доступ к микрофону" "Доступ к микрофону запрещён. Разрешите его в настройках приложения, затем повторите звонок или нажатие PTT." diff --git a/tools/chatgui-android/app/src/main/res/values/strings.xml b/tools/chatgui-android/app/src/main/res/values/strings.xml index 92404e8d..1324969d 100644 --- a/tools/chatgui-android/app/src/main/res/values/strings.xml +++ b/tools/chatgui-android/app/src/main/res/values/strings.xml @@ -1,5 +1,20 @@ + Live streams + Start stream + Stop stream + Streaming live + Connecting… + Finishing stream… + Stream ended + Could not start or continue the stream + Waiting for a key frame… + No active streams in this channel + Watch %1$s + Close player + Front camera + Back camera + Stop the active call, radio, recording or stream before starting another capture "Allow microphone access for calls and PTT" "Could not request microphone access" "Microphone access is denied. Allow it in app settings, then try the call or PTT again."