3 changed files with 183 additions and 0 deletions
@ -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 |
||||
}) |
||||
} |
||||
Loading…
Reference in new issue