Browse Source

Add localized Android stream controls and player screen

master
evgeny 1 week ago
parent
commit
8502d6f2d7
  1. 153
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamScreen.kt
  2. 15
      tools/chatgui-android/app/src/main/res/values-ru/strings.xml
  3. 15
      tools/chatgui-android/app/src/main/res/values/strings.xml

153
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<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
})
}

15
tools/chatgui-android/app/src/main/res/values-ru/strings.xml

@ -1,5 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="stream_title">Трансляции</string>
<string name="stream_start">Начать трансляцию</string>
<string name="stream_stop">Остановить трансляцию</string>
<string name="stream_live">Прямая трансляция</string>
<string name="stream_connecting">Подключение…</string>
<string name="stream_stopping">Завершение трансляции…</string>
<string name="stream_ended">Трансляция завершена</string>
<string name="stream_failed">Не удалось запустить или продолжить трансляцию</string>
<string name="stream_waiting_key">Ожидание ключевого кадра…</string>
<string name="stream_none">В этом канале нет активных трансляций</string>
<string name="stream_watch">Смотреть: %1$s</string>
<string name="stream_close_view">Закрыть просмотр</string>
<string name="stream_front_camera">Фронтальная камера</string>
<string name="stream_back_camera">Задняя камера</string>
<string name="stream_resources_busy">Остановите активный звонок, рацию, запись или трансляцию перед новым захватом</string>
<string name="microphone_permission_calls">"Для звонков и PTT разрешите доступ к микрофону"</string>
<string name="microphone_request_failed">"Не удалось запросить доступ к микрофону"</string>
<string name="microphone_permission_settings">"Доступ к микрофону запрещён. Разрешите его в настройках приложения, затем повторите звонок или нажатие PTT."</string>

15
tools/chatgui-android/app/src/main/res/values/strings.xml

@ -1,5 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="stream_title">Live streams</string>
<string name="stream_start">Start stream</string>
<string name="stream_stop">Stop stream</string>
<string name="stream_live">Streaming live</string>
<string name="stream_connecting">Connecting…</string>
<string name="stream_stopping">Finishing stream…</string>
<string name="stream_ended">Stream ended</string>
<string name="stream_failed">Could not start or continue the stream</string>
<string name="stream_waiting_key">Waiting for a key frame…</string>
<string name="stream_none">No active streams in this channel</string>
<string name="stream_watch">Watch %1$s</string>
<string name="stream_close_view">Close player</string>
<string name="stream_front_camera">Front camera</string>
<string name="stream_back_camera">Back camera</string>
<string name="stream_resources_busy">Stop the active call, radio, recording or stream before starting another capture</string>
<string name="microphone_permission_calls">"Allow microphone access for calls and PTT"</string>
<string name="microphone_request_failed">"Could not request microphone access"</string>
<string name="microphone_permission_settings">"Microphone access is denied. Allow it in app settings, then try the call or PTT again."</string>

Loading…
Cancel
Save