diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt index 139de16e..4f379d38 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt @@ -394,7 +394,8 @@ class MainActivity : AppCompatActivity() { }, onInvite = { showInvite(channel) }, onBack = { screen = Screen.ChannelList }, - onStreams = { screen = Screen.Streams }, + onStreams = { vm.selectStream(null); screen = Screen.Streams }, + onWatchStream = { vm.selectStream(it); screen = Screen.Streams }, onPlayVideo = { msg -> videoMessage = msg }, onViewPhoto = { msg -> photoMessage = msg }, onRecordVideo = { @@ -412,7 +413,12 @@ class MainActivity : AppCompatActivity() { } Screen.Streams -> { vm.currentChannel.collectAsState().value?.let { channel -> - StreamScreen(channel.id, channel.name) { screen = Screen.Chat } + val sources by vm.streamSources.collectAsState() + val selected by vm.streamSelection.collectAsState() + val failed by vm.streamSourcesFailed.collectAsState() + StreamScreen(channel.id, channel.name, sources, selected, failed, vm::selectStream) { + vm.selectStream(null); screen = Screen.Chat + } } } Screen.MemberList -> { diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt index 9cf31f29..70fe6b3f 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt @@ -70,6 +70,8 @@ import com.utun.chat.ui.components.GroupAvatar import com.utun.chat.ui.components.GroupPropertiesDialog import com.utun.chat.data.ConversationTarget import com.utun.chat.viewmodel.ChatViewModel +import com.utun.chat.data.StreamSource +import androidx.compose.material.icons.filled.LiveTv import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -89,7 +91,8 @@ fun ChatScreen( onViewPhoto: (Message) -> Unit = {}, onRecordVideo: () -> Unit = {}, onCapturePhoto: () -> Unit = {}, - onStreams: () -> Unit = {} + onStreams: () -> Unit = {}, + onWatchStream: (StreamSource) -> Unit = {} ) { val listState = rememberLazyListState() val context = LocalContext.current @@ -98,6 +101,23 @@ fun ChatScreen( val scope = rememberCoroutineScope() var properties by remember(channel.id) { mutableStateOf(false) } var menu by remember(channel.id) { mutableStateOf(false) } + val streams by viewModel.streamSources.collectAsState() + var chooseStream by remember(channel.id) { mutableStateOf(false) } + if (chooseStream && streams.size > 1) AlertDialog( + onDismissRequest = { chooseStream = false }, + title = { Text(stringResource(R.string.stream_choose_source)) }, + text = { + Column { + streams.forEach { source -> + TextButton(onClick = { chooseStream = false; onWatchStream(source) }) { + Text("${source.displayName} · ${source.id.toULong()}") + } + } + } + }, + confirmButton = { TextButton(onClick = { chooseStream = false }) { Text(stringResource(R.string.cancel)) } } + ) + LaunchedEffect(streams) { if (streams.size <= 1) chooseStream = false } /* рация */ val radioActive by viewModel.radioActive.collectAsState() @@ -172,6 +192,13 @@ fun ChatScreen( IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back)) } }, actions = { + if (streams.isNotEmpty()) IconButton(onClick = { + if (streams.size == 1) onWatchStream(streams.single()) else chooseStream = true + }) { + BadgedBox(badge = { if (streams.size > 1) Badge { Text(streams.size.toString()) } }) { + Icon(Icons.Filled.LiveTv, stringResource(R.string.stream_available, streams.size), tint = Color(0xFFE53935)) + } + } IconButton( onClick = { viewModel.toggleRadio() }, modifier = Modifier.background( 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 index d8cdef33..3e22c3e6 100644 --- 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 @@ -32,23 +32,21 @@ 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 com.utun.chat.data.StreamSource import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext import org.json.JSONArray -private data class LiveSource(val id: Long, val name: String, val epoch: String, val self: Boolean) - /** Выход с экрана закрывает только reader; foreground publisher останавливает явная кнопка/notification. */ @OptIn(ExperimentalMaterial3Api::class) @Composable -fun StreamScreen(channelId: String, channelName: String, onBack: () -> Unit) { +fun StreamScreen(channelId: String, channelName: String, sources: List, selected: StreamSource?, + sourcesFailed: Boolean, onSelect: (StreamSource?) -> Unit, 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 previewedSession by remember(group) { mutableStateOf(null) } var audio by remember { mutableStateOf(true) } var camera by remember { mutableStateOf("") } @@ -64,24 +62,10 @@ fun StreamScreen(channelId: String, channelName: String, onBack: () -> Unit) { 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.streamSources(group)) - (0 until json.length()).map { i -> - val member = json.getJSONObject(i) - LiveSource(member.getString("id").toULong().toLong(), member.getString("name"), member.getString("epoch"), member.optInt("self") == 1) - } - } catch (e: Exception) { LogManager.addLog("WARN", "stream", "stream list: ${e.message}"); emptyList() } - } else sources = emptyList() - delay(1000) - } - } LaunchedEffect(sources, local?.id, local?.phase) { - if (local != null && !local.ongoing && selected?.self == true) selected = null + if (local != null && !local.ongoing && selected?.self == true) onSelect(null) if (local?.phase == StreamPhase.LIVE && previewedSession != local.id) sources.firstOrNull { it.self }?.let { - selected = it; previewedSession = local.id + onSelect(it); previewedSession = local.id } } @@ -92,6 +76,14 @@ fun StreamScreen(channelId: String, channelName: String, onBack: () -> Unit) { }) { padding -> Column(Modifier.fillMaxSize().padding(padding).padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Text(channelName, style = MaterialTheme.typography.titleMedium) + if (selected != null) { + Text(selected.displayName, style = MaterialTheme.typography.titleSmall) + Box(Modifier.weight(1f).fillMaxWidth()) { + key(group, selected.id, selected.epoch) { LiveStreamView(group, selected.id, selected.epoch, selected.self) } + } + TextButton(onClick = { onSelect(null); onBack() }) { Text(stringResource(R.string.stream_close_view)) } + return@Column + } if (local?.ongoing == true) { Text(stringResource(when (local.phase) { StreamPhase.LIVE -> R.string.stream_live @@ -117,24 +109,19 @@ fun StreamScreen(channelId: String, channelName: String, onBack: () -> Unit) { }, 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)) + if (sourcesFailed) Text(stringResource(R.string.stream_list_failed), color = MaterialTheme.colorScheme.error) + else if (sources.isEmpty() && local?.ongoing != true) Text(stringResource(R.string.stream_none)) Column(Modifier.fillMaxWidth().heightIn(max = 160.dp).verticalScroll(rememberScrollState())) { sources.forEach { source -> - TextButton(onClick = { selected = source }) { Text(stringResource(R.string.stream_watch, source.name.ifEmpty { source.id.toULong().toString() })) } - } - } - selected?.let { source -> - Box(Modifier.weight(1f).fillMaxWidth()) { - key(group, source.id, source.epoch) { LiveStreamView(group, source.id, source.self) } + TextButton(onClick = { onSelect(source) }) { Text(stringResource(R.string.stream_watch, source.displayName)) } } - TextButton(onClick = { selected = null }) { Text(stringResource(R.string.stream_close_view)) } } } } } @Composable -private fun LiveStreamView(group: Long, source: Long, localPreview: Boolean) { +private fun LiveStreamView(group: Long, source: Long, epoch: Long, localPreview: Boolean) { val owner = LocalLifecycleOwner.current var visible by remember(owner) { mutableStateOf(owner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) } DisposableEffect(owner) { @@ -145,12 +132,12 @@ private fun LiveStreamView(group: Long, source: Long, localPreview: Boolean) { owner.lifecycle.addObserver(observer) onDispose { owner.lifecycle.removeObserver(observer) } } - if (visible) LiveStreamSurface(group, source, localPreview) + if (visible) LiveStreamSurface(group, source, epoch, localPreview) } @Composable -private fun LiveStreamSurface(group: Long, source: Long, localPreview: Boolean) { - val engine = remember(group, source, localPreview) { StreamPlayerEngine(group, source, localPreview) } +private fun LiveStreamSurface(group: Long, source: Long, epoch: Long, localPreview: Boolean) { + val engine = remember(group, source, epoch, localPreview) { StreamPlayerEngine(group, source, epoch, localPreview) } val aspect by engine.aspect.collectAsState() val status by engine.status.collectAsState() var surface by remember { mutableStateOf(null) }