Open Android live streams from the channel header
This commit is contained in:
1 parent
ba3dd70f6c
commit
d5ed19ee34
3 files changed
+56
-36
No files matched your search
@@ -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 -> {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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() })) }
|
||||
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
|
||||
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) }
|
||||
|
||||
Reference in new issue
Block a user