Browse Source

Open Android live streams from the channel header

master
evgeny 1 week ago
parent
commit
d5ed19ee34
  1. 10
      tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt
  2. 29
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt
  3. 53
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamScreen.kt

10
tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt

@ -394,7 +394,8 @@ class MainActivity : AppCompatActivity() {
}, },
onInvite = { showInvite(channel) }, onInvite = { showInvite(channel) },
onBack = { screen = Screen.ChannelList }, 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 }, onPlayVideo = { msg -> videoMessage = msg },
onViewPhoto = { msg -> photoMessage = msg }, onViewPhoto = { msg -> photoMessage = msg },
onRecordVideo = { onRecordVideo = {
@ -412,7 +413,12 @@ class MainActivity : AppCompatActivity() {
} }
Screen.Streams -> { Screen.Streams -> {
vm.currentChannel.collectAsState().value?.let { channel -> 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 -> { Screen.MemberList -> {

29
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.ui.components.GroupPropertiesDialog
import com.utun.chat.data.ConversationTarget import com.utun.chat.data.ConversationTarget
import com.utun.chat.viewmodel.ChatViewModel 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.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -89,7 +91,8 @@ fun ChatScreen(
onViewPhoto: (Message) -> Unit = {}, onViewPhoto: (Message) -> Unit = {},
onRecordVideo: () -> Unit = {}, onRecordVideo: () -> Unit = {},
onCapturePhoto: () -> Unit = {}, onCapturePhoto: () -> Unit = {},
onStreams: () -> Unit = {} onStreams: () -> Unit = {},
onWatchStream: (StreamSource) -> Unit = {}
) { ) {
val listState = rememberLazyListState() val listState = rememberLazyListState()
val context = LocalContext.current val context = LocalContext.current
@ -98,6 +101,23 @@ fun ChatScreen(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var properties by remember(channel.id) { mutableStateOf(false) } var properties by remember(channel.id) { mutableStateOf(false) }
var menu 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() val radioActive by viewModel.radioActive.collectAsState()
@ -172,6 +192,13 @@ fun ChatScreen(
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back)) } IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back)) }
}, },
actions = { 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( IconButton(
onClick = { viewModel.toggleRadio() }, onClick = { viewModel.toggleRadio() },
modifier = Modifier.background( modifier = Modifier.background(

53
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.StreamController
import com.utun.chat.data.StreamPhase import com.utun.chat.data.StreamPhase
import com.utun.chat.data.StreamPlayerEngine import com.utun.chat.data.StreamPlayerEngine
import com.utun.chat.data.StreamSource
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.json.JSONArray import org.json.JSONArray
private data class LiveSource(val id: Long, val name: String, val epoch: String, val self: Boolean)
/** Выход с экрана закрывает только reader; foreground publisher останавливает явная кнопка/notification. */ /** Выход с экрана закрывает только reader; foreground publisher останавливает явная кнопка/notification. */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun StreamScreen(channelId: String, channelName: String, onBack: () -> Unit) { fun StreamScreen(channelId: String, channelName: String, sources: List<StreamSource>, selected: StreamSource?,
sourcesFailed: Boolean, onSelect: (StreamSource?) -> Unit, onBack: () -> Unit) {
val context = LocalContext.current val context = LocalContext.current
val group = remember(channelId) { channelId.toULongOrNull()?.toLong() ?: 0L } val group = remember(channelId) { channelId.toULongOrNull()?.toLong() ?: 0L }
val state by StreamController.state.collectAsState() val state by StreamController.state.collectAsState()
val local = state?.takeIf { it.group == group } 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 previewedSession by remember(group) { mutableStateOf<Long?>(null) } var previewedSession by remember(group) { mutableStateOf<Long?>(null) }
var audio by remember { mutableStateOf(true) } var audio by remember { mutableStateOf(true) }
var camera by remember { mutableStateOf("") } 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 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 } 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) { 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 { 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 -> }) { padding ->
Column(Modifier.fillMaxSize().padding(padding).padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(Modifier.fillMaxSize().padding(padding).padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(channelName, style = MaterialTheme.typography.titleMedium) 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) { if (local?.ongoing == true) {
Text(stringResource(when (local.phase) { Text(stringResource(when (local.phase) {
StreamPhase.LIVE -> R.string.stream_live 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)) } }, enabled = camera.isNotEmpty() && group != 0L && state?.ongoing != true) { Text(stringResource(R.string.stream_start)) }
} }
local?.error?.let { Text(stringResource(it), color = MaterialTheme.colorScheme.error) } 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())) { Column(Modifier.fillMaxWidth().heightIn(max = 160.dp).verticalScroll(rememberScrollState())) {
sources.forEach { source -> sources.forEach { source ->
TextButton(onClick = { selected = source }) { Text(stringResource(R.string.stream_watch, source.name.ifEmpty { source.id.toULong().toString() })) } TextButton(onClick = { onSelect(source) }) { Text(stringResource(R.string.stream_watch, source.displayName)) }
}
}
selected?.let { source ->
Box(Modifier.weight(1f).fillMaxWidth()) {
key(group, source.id, source.epoch) { LiveStreamView(group, source.id, source.self) }
} }
TextButton(onClick = { selected = null }) { Text(stringResource(R.string.stream_close_view)) }
} }
} }
} }
} }
@Composable @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 val owner = LocalLifecycleOwner.current
var visible by remember(owner) { mutableStateOf(owner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) } var visible by remember(owner) { mutableStateOf(owner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) }
DisposableEffect(owner) { DisposableEffect(owner) {
@ -145,12 +132,12 @@ private fun LiveStreamView(group: Long, source: Long, localPreview: Boolean) {
owner.lifecycle.addObserver(observer) owner.lifecycle.addObserver(observer)
onDispose { owner.lifecycle.removeObserver(observer) } onDispose { owner.lifecycle.removeObserver(observer) }
} }
if (visible) LiveStreamSurface(group, source, localPreview) if (visible) LiveStreamSurface(group, source, epoch, localPreview)
} }
@Composable @Composable
private fun LiveStreamSurface(group: Long, source: Long, localPreview: Boolean) { private fun LiveStreamSurface(group: Long, source: Long, epoch: Long, localPreview: Boolean) {
val engine = remember(group, source, localPreview) { StreamPlayerEngine(group, source, localPreview) } val engine = remember(group, source, epoch, localPreview) { StreamPlayerEngine(group, source, epoch, localPreview) }
val aspect by engine.aspect.collectAsState() val aspect by engine.aspect.collectAsState()
val status by engine.status.collectAsState() val status by engine.status.collectAsState()
var surface by remember { mutableStateOf<Surface?>(null) } var surface by remember { mutableStateOf<Surface?>(null) }

Loading…
Cancel
Save