|
|
|
|
@ -71,7 +71,8 @@ fun StreamScreen(channelId: String, channelName: String, sources: List<StreamSou
|
|
|
|
|
var audio by remember { mutableStateOf(true) } |
|
|
|
|
var mode by remember { mutableStateOf<StreamCaptureMode?>(null) } |
|
|
|
|
var orientation by rememberSaveable { mutableStateOf(StreamOrientation.UPRIGHT) } |
|
|
|
|
val config = mode?.let { StreamCaptureConfig(it, orientation) } |
|
|
|
|
var videoBitrate by rememberSaveable { mutableStateOf<Int?>(null) } |
|
|
|
|
val config = mode?.let { StreamCaptureConfig(it, orientation, videoBitrate) } |
|
|
|
|
var cameras by remember { mutableStateOf<List<StreamCamera>>(emptyList()) } |
|
|
|
|
var loadingModes by remember { mutableStateOf(true) } |
|
|
|
|
LaunchedEffect(Unit) { |
|
|
|
|
@ -114,6 +115,7 @@ fun StreamScreen(channelId: String, channelName: String, sources: List<StreamSou
|
|
|
|
|
CaptureOptions(cameras, local.pendingConfig ?: local.config, local.phase == StreamPhase.LIVE && local.pendingConfig == null, |
|
|
|
|
StreamController::change) |
|
|
|
|
CaptureModeStatus(local.config, local.pendingConfig, local.notice) |
|
|
|
|
StreamTxPanel(local.id) |
|
|
|
|
} |
|
|
|
|
Box(Modifier.weight(1f).fillMaxWidth()) { |
|
|
|
|
key(group, selected.id, selected.epoch) { LiveStreamView(group, selected.id, selected.epoch, if (selected.self) local?.preview else null) } |
|
|
|
|
@ -126,6 +128,7 @@ fun StreamScreen(channelId: String, channelName: String, sources: List<StreamSou
|
|
|
|
|
CaptureOptions(cameras, local.pendingConfig ?: local.config, local.phase == StreamPhase.LIVE && local.pendingConfig == null, |
|
|
|
|
StreamController::change) |
|
|
|
|
CaptureModeStatus(local.config, local.pendingConfig, local.notice) |
|
|
|
|
StreamTxPanel(local.id) |
|
|
|
|
Text(stringResource(when (local.phase) { |
|
|
|
|
StreamPhase.LIVE -> R.string.stream_live |
|
|
|
|
StreamPhase.STOPPING -> R.string.stream_stopping |
|
|
|
|
@ -133,7 +136,7 @@ fun StreamScreen(channelId: String, channelName: String, sources: List<StreamSou
|
|
|
|
|
})) |
|
|
|
|
Button(onClick = StreamController::stop, enabled = local.phase != StreamPhase.STOPPING) { Text(stringResource(R.string.stream_stop)) } |
|
|
|
|
} else { |
|
|
|
|
CaptureOptions(cameras, config, !loadingModes) { mode = it.mode; orientation = it.orientation } |
|
|
|
|
CaptureOptions(cameras, config, !loadingModes) { mode = it.mode; orientation = it.orientation; videoBitrate = it.videoBitrate } |
|
|
|
|
if (!loadingModes && cameras.isEmpty()) Text(stringResource(R.string.stream_no_modes), color = MaterialTheme.colorScheme.error) |
|
|
|
|
Row { Checkbox(checked = audio, onCheckedChange = { audio = it }); Text(stringResource(R.string.microphone), Modifier.padding(top = 12.dp)) } |
|
|
|
|
Button(onClick = { |
|
|
|
|
@ -158,11 +161,24 @@ fun StreamScreen(channelId: String, channelName: String, sources: List<StreamSou
|
|
|
|
|
} |
|
|
|
|
@Composable |
|
|
|
|
private fun CaptureModeStatus(config: StreamCaptureConfig?, pending: StreamCaptureConfig?, notice: Int?) { |
|
|
|
|
config?.let { Text(it.mode.label, style = MaterialTheme.typography.bodySmall) } |
|
|
|
|
config?.let { Text("${it.mode.label} · ${it.mode.videoCodec.label} · " + stringResource(R.string.stream_bitrate_value, it.bitrate / 1_000_000.0), |
|
|
|
|
style = MaterialTheme.typography.bodySmall) } |
|
|
|
|
if (pending != null) Text(stringResource(if (config == null) R.string.stream_connecting else R.string.stream_switching)) |
|
|
|
|
notice?.let { Text(stringResource(it), color = MaterialTheme.colorScheme.error) } |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
@Composable |
|
|
|
|
private fun StreamTxPanel(id: Long) { |
|
|
|
|
val snapshot by StreamController.txStats.collectAsState() |
|
|
|
|
val s = snapshot?.takeIf { it.id == id } ?: return |
|
|
|
|
Column { |
|
|
|
|
Text(stringResource(R.string.stream_tx_queues, s.videoBytes / 1024.0, s.inflightBytes / 1024.0), |
|
|
|
|
style = MaterialTheme.typography.bodySmall) |
|
|
|
|
Text(stringResource(R.string.stream_tx_total, s.totalBytes / 1024.0, s.acked1s / 1024.0, s.acked1s * 8 / 1_000_000.0), |
|
|
|
|
style = MaterialTheme.typography.bodySmall) |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
@Composable |
|
|
|
|
private fun CaptureOptions(cameras: List<StreamCamera>, config: StreamCaptureConfig?, enabled: Boolean, |
|
|
|
|
onChange: (StreamCaptureConfig) -> Unit) { |
|
|
|
|
@ -170,12 +186,51 @@ private fun CaptureOptions(cameras: List<StreamCamera>, config: StreamCaptureCon
|
|
|
|
|
Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp)) { |
|
|
|
|
cameras.forEach { camera -> |
|
|
|
|
FilterChip(selected = camera.id == mode?.camera, enabled = enabled, onClick = { |
|
|
|
|
StreamCameraModes.choose(camera.modes, mode)?.let { onChange(StreamCaptureConfig(it, config?.orientation ?: StreamOrientation.UPRIGHT)) } |
|
|
|
|
StreamCameraModes.choose(camera.modes, mode)?.let { onChange(config?.withMode(it) ?: StreamCaptureConfig(it)) } |
|
|
|
|
}, label = { Text("${camera.lens.label} ${camera.lens.mm}".trim()) }) |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
val camera = cameras.firstOrNull { it.id == mode?.camera } |
|
|
|
|
val modes = camera?.modes.orEmpty() |
|
|
|
|
val allModes = camera?.modes.orEmpty() |
|
|
|
|
val modes = allModes.filter { it.videoCodec == mode?.videoCodec } |
|
|
|
|
var codecsExpanded by remember { mutableStateOf(false) } |
|
|
|
|
var bitrateExpanded by remember { mutableStateOf(false) } |
|
|
|
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { |
|
|
|
|
Box(Modifier.weight(1f)) { |
|
|
|
|
OutlinedButton(onClick = { codecsExpanded = true }, enabled = enabled && config != null, modifier = Modifier.fillMaxWidth()) { |
|
|
|
|
Text(stringResource(R.string.stream_video_codec, mode?.videoCodec?.label.orEmpty())) |
|
|
|
|
} |
|
|
|
|
DropdownMenu(expanded = codecsExpanded && enabled, onDismissRequest = { codecsExpanded = false }) { |
|
|
|
|
allModes.map { it.videoCodec }.distinct().sortedBy { it.id }.forEach { codec -> |
|
|
|
|
DropdownMenuItem(text = { Text(codec.label) }, onClick = { |
|
|
|
|
codecsExpanded = false |
|
|
|
|
StreamCameraModes.choose(allModes.filter { it.videoCodec == codec }, mode)?.let { next -> |
|
|
|
|
config?.let { onChange(it.withMode(next)) } |
|
|
|
|
} |
|
|
|
|
}) |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
Box(Modifier.weight(1f)) { |
|
|
|
|
OutlinedButton(onClick = { bitrateExpanded = true }, enabled = enabled && config != null, modifier = Modifier.fillMaxWidth()) { |
|
|
|
|
Text(if (config?.videoBitrate == null) stringResource(R.string.stream_bitrate_auto) |
|
|
|
|
else stringResource(R.string.stream_bitrate_value, config.bitrate / 1_000_000.0)) |
|
|
|
|
} |
|
|
|
|
DropdownMenu(expanded = bitrateExpanded && enabled, onDismissRequest = { bitrateExpanded = false }) { |
|
|
|
|
DropdownMenuItem(text = { Text(stringResource(R.string.stream_bitrate_auto)) }, onClick = { |
|
|
|
|
bitrateExpanded = false; config?.let { onChange(it.copy(videoBitrate = null)) } |
|
|
|
|
}) |
|
|
|
|
mode?.let { m -> |
|
|
|
|
listOf(256000, 512000, 1000000, 2000000, 4000000, 6000000, 8000000, 12000000, 16000000, 24000000, 32000000, 48000000, 64000000) |
|
|
|
|
.map { it.coerceIn(m.minimumBitrate, m.maximumBitrate) }.distinct().forEach { value -> |
|
|
|
|
DropdownMenuItem(text = { Text(stringResource(R.string.stream_bitrate_value, value / 1_000_000.0)) }, onClick = { |
|
|
|
|
bitrateExpanded = false; config?.let { onChange(it.copy(videoBitrate = value)) } |
|
|
|
|
}) |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
val context = LocalContext.current |
|
|
|
|
val configuration = LocalConfiguration.current |
|
|
|
|
val display = remember(configuration) { ((context as? Activity)?.windowManager?.defaultDisplay?.rotation ?: 0) * 90 } |
|
|
|
|
@ -185,7 +240,7 @@ private fun CaptureOptions(cameras: List<StreamCamera>, config: StreamCaptureCon
|
|
|
|
|
OutlinedButton(onClick = { expanded = true }, enabled = enabled && modes.isNotEmpty()) { Text(mode?.label.orEmpty()) } |
|
|
|
|
DropdownMenu(expanded = expanded && enabled, onDismissRequest = { expanded = false }) { |
|
|
|
|
modes.forEach { option -> DropdownMenuItem(text = { Text(option.label) }, onClick = { |
|
|
|
|
expanded = false; config?.let { onChange(it.copy(mode = option)) } |
|
|
|
|
expanded = false; config?.let { onChange(it.withMode(option)) } |
|
|
|
|
}) } |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|