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