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) },
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 -> {

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.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(

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.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<StreamSource>, 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<List<LiveSource>>(emptyList()) }
var selected by remember(group) { mutableStateOf<LiveSource?>(null) }
var previewedSession by remember(group) { mutableStateOf<Long?>(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<Surface?>(null) }

Loading…
Cancel
Save