Free Android stream preview space with start and stop icons
This commit is contained in:
1 parent
d0bbe8444e
commit
bdb6bf80a4
2 files changed
+71
-56
No files matched your search
@@ -9,6 +9,9 @@
|
|||||||
В логе получатель дважды отклонил приглашение и отправил ACK; отправитель до проверки получил только late ACK request=507.
|
В логе получатель дважды отклонил приглашение и отправил ACK; отправитель до проверки получил только late ACK request=507.
|
||||||
Отдельный повтор прошёл. Причина не установлена; проверить доставку UDP и шаги ускоренных часов под параллельной нагрузкой.
|
Отдельный повтор прошёл. Причина не установлена; проверить доставку UDP и шаги ускоренных часов под параллельной нагрузкой.
|
||||||
Логи: /tmp/config-name-local-transport-failure.log, /tmp/config-name-local-transport-retry.log.
|
Логи: /tmp/config-name-local-transport-failure.log, /tmp/config-name-local-transport-retry.log.
|
||||||
|
Ещё один общий прогон 19.10: 161/162, chat_adapter упал на строке 145 при ожидании доставки request=501.
|
||||||
|
Получатель принял приглашение и дважды отправил ACK; до проверки в логе отправителя нет приёма этого ACK. Отдельный повтор прошёл.
|
||||||
|
Логи: /tmp/utun-stream-preview-local-transport-failure.log, /tmp/utun-stream-preview-local-transport-retry.log.
|
||||||
|
|
||||||
[ ] **Разобрать нестабильную проверку аудиопауз desktop-трансляции** — 17.10.2026:
|
[ ] **Разобрать нестабильную проверку аудиопауз desktop-трансляции** — 17.10.2026:
|
||||||
При проверке зума test_stream_view::realH264RenderingAndClose получил silenceGaps=3 вместо gapsBefore=2.
|
При проверке зума test_stream_view::realH264RenderingAndClose получил silenceGaps=3 вместо gapsBefore=2.
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|||||||
import androidx.compose.material.icons.automirrored.filled.VolumeOff
|
import androidx.compose.material.icons.automirrored.filled.VolumeOff
|
||||||
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material.icons.filled.Stop
|
||||||
import androidx.compose.material.icons.outlined.Person
|
import androidx.compose.material.icons.outlined.Person
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
@@ -35,6 +36,7 @@ import androidx.compose.ui.geometry.Offset
|
|||||||
import androidx.compose.ui.geometry.Size
|
import androidx.compose.ui.geometry.Size
|
||||||
import androidx.compose.ui.graphics.StrokeCap
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
import androidx.compose.ui.graphics.Path
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
import androidx.compose.ui.graphics.drawscope.rotate
|
import androidx.compose.ui.graphics.drawscope.rotate
|
||||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
@@ -145,54 +147,57 @@ fun StreamScreen(channelId: String, channelName: String, sources: List<StreamSou
|
|||||||
})
|
})
|
||||||
}) { 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)) {
|
||||||
StreamCaptureToolbar(cameras, shownConfig, displayDegrees,
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
enabled = if (ongoing != null) ongoing.phase == StreamPhase.LIVE && ongoing.pendingConfig == null else !loadingModes,
|
StreamCaptureToolbar(cameras, shownConfig, displayDegrees, Modifier.weight(1f),
|
||||||
audio = shownAudio,
|
enabled = if (ongoing != null) ongoing.phase == StreamPhase.LIVE && ongoing.pendingConfig == null else !loadingModes,
|
||||||
audioEnabled = if (ongoing != null) ongoing.phase == StreamPhase.LIVE && ongoing.audioAvailable else !loadingModes,
|
audio = shownAudio,
|
||||||
onAudioChange = {
|
audioEnabled = if (ongoing != null) ongoing.phase == StreamPhase.LIVE && ongoing.audioAvailable else !loadingModes,
|
||||||
LogManager.addLog("INFO", "stream", "screen audio group=$group enabled=$it live=${ongoing != null}")
|
onAudioChange = {
|
||||||
if (ongoing != null) StreamController.setAudioEnabled(it)
|
LogManager.addLog("INFO", "stream", "screen audio group=$group enabled=$it live=${ongoing != null}")
|
||||||
else { audio = it; StreamController.rememberSettings(audio = it) }
|
if (ongoing != null) StreamController.setAudioEnabled(it)
|
||||||
}, onChange = {
|
else { audio = it; StreamController.rememberSettings(audio = it) }
|
||||||
LogManager.addLog("INFO", "stream", "screen capture selection group=$group live=${ongoing != null} config=$it")
|
}, onChange = {
|
||||||
if (ongoing != null) StreamController.change(it)
|
LogManager.addLog("INFO", "stream", "screen capture selection group=$group live=${ongoing != null} config=$it")
|
||||||
else { config = it; StreamController.rememberSettings(config = it) }
|
if (ongoing != null) StreamController.change(it)
|
||||||
})
|
else { config = it; StreamController.rememberSettings(config = it) }
|
||||||
if (selected != null) {
|
})
|
||||||
Text(selected.displayName, style = MaterialTheme.typography.titleSmall)
|
val action = if (ongoing == null) R.string.stream_start
|
||||||
if (selected.self && local?.ongoing == true) {
|
else if (ongoing.phase == StreamPhase.STOPPING) R.string.stream_stopping else R.string.stream_stop
|
||||||
CaptureModeStatus(local.config, local.pendingConfig, local.notice)
|
StreamControlButton(stringResource(action),
|
||||||
StreamTxPanel(local.id)
|
enabled = if (ongoing != null) ongoing.phase != StreamPhase.STOPPING
|
||||||
|
else !loadingModes && config != null && group != 0L && state?.ongoing != true,
|
||||||
|
containerColor = if (ongoing != null) Color(0xFFB32632) else Color(0xFF24633D), onClick = {
|
||||||
|
if (ongoing != null) {
|
||||||
|
LogManager.addLog("INFO", "stream", "screen stop group=$group id=${ongoing.id} phase=${ongoing.phase}")
|
||||||
|
StreamController.stop()
|
||||||
|
} else if (!cameraGranted || (audio && !micGranted)) {
|
||||||
|
LogManager.addLog("INFO", "stream", "screen permissions group=$group camera=$cameraGranted mic=$micGranted audio=$audio")
|
||||||
|
permissions.launch(listOf(Manifest.permission.CAMERA)
|
||||||
|
.plus(if (audio) listOf(Manifest.permission.RECORD_AUDIO) else emptyList()).toTypedArray())
|
||||||
|
} else config?.let {
|
||||||
|
LogManager.addLog("INFO", "stream", "screen start group=$group audio=$audio config=$it")
|
||||||
|
StreamController.start(group, it, audio)
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Icon(if (ongoing != null) Icons.Filled.Stop else Icons.Filled.PlayArrow,
|
||||||
|
contentDescription = null, modifier = Modifier.size(32.dp))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if (ongoing != null) {
|
||||||
|
CaptureModeStatus(ongoing.config, ongoing.pendingConfig, ongoing.notice)
|
||||||
|
if (ongoing.phase == StreamPhase.STOPPING) Text(stringResource(R.string.stream_stopping))
|
||||||
|
StreamTxPanel(ongoing.id)
|
||||||
|
}
|
||||||
|
if (selected != null) {
|
||||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||||
if (selected.self && local?.ongoing != true) Text(stringResource(R.string.stream_ended))
|
if (selected.self && local?.ongoing != true) Text(stringResource(R.string.stream_ended))
|
||||||
else key(group, selected.id, selected.epoch) {
|
else key(group, selected.id, selected.epoch) {
|
||||||
local?.preview?.let { LiveStreamView(it, displayDegrees) }
|
local?.preview?.let { LiveStreamView(it, displayDegrees) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
IconButton(onClick = { onSelect(null); onBack() }) { Icon(Icons.Filled.Close, stringResource(R.string.stream_close_view)) }
|
|
||||||
if (selected.self && local?.ongoing == true) Button(onClick = StreamController::stop,
|
|
||||||
enabled = local.phase != StreamPhase.STOPPING) { Text(stringResource(R.string.stream_stop)) }
|
|
||||||
} else {
|
} else {
|
||||||
if (local?.ongoing == true) {
|
if (ongoing == null && !loadingModes && cameras.isEmpty())
|
||||||
CaptureModeStatus(local.config, local.pendingConfig, local.notice)
|
Text(stringResource(R.string.stream_no_modes), color = MaterialTheme.colorScheme.error)
|
||||||
StreamTxPanel(local.id)
|
|
||||||
Text(stringResource(when (local.phase) {
|
|
||||||
StreamPhase.LIVE -> R.string.stream_live
|
|
||||||
StreamPhase.STOPPING -> R.string.stream_stopping
|
|
||||||
else -> R.string.stream_connecting
|
|
||||||
}))
|
|
||||||
Button(onClick = StreamController::stop, enabled = local.phase != StreamPhase.STOPPING) { Text(stringResource(R.string.stream_stop)) }
|
|
||||||
} else {
|
|
||||||
if (!loadingModes && cameras.isEmpty()) Text(stringResource(R.string.stream_no_modes), color = MaterialTheme.colorScheme.error)
|
|
||||||
Button(onClick = {
|
|
||||||
if (!cameraGranted || (audio && !micGranted)) permissions.launch(
|
|
||||||
listOf(Manifest.permission.CAMERA).plus(if (audio) listOf(Manifest.permission.RECORD_AUDIO) else emptyList()).toTypedArray())
|
|
||||||
else {
|
|
||||||
config?.let { StreamController.start(group, it, audio) }
|
|
||||||
}
|
|
||||||
}, enabled = config != null && 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 (sourcesFailed) Text(stringResource(R.string.stream_list_failed), color = MaterialTheme.colorScheme.error)
|
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))
|
else if (sources.isEmpty() && local?.ongoing != true) Text(stringResource(R.string.stream_none))
|
||||||
@@ -217,9 +222,9 @@ private fun StreamTxPanel(id: Long) {
|
|||||||
val s = snapshot?.takeIf { it.id == id } ?: return
|
val s = snapshot?.takeIf { it.id == id } ?: return
|
||||||
Column {
|
Column {
|
||||||
Text(stringResource(R.string.stream_tx_queues, s.videoBytes / 1024.0, s.inflightBytes / 1024.0),
|
Text(stringResource(R.string.stream_tx_queues, s.videoBytes / 1024.0, s.inflightBytes / 1024.0),
|
||||||
style = MaterialTheme.typography.bodySmall, minLines = 2)
|
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),
|
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, minLines = 2)
|
style = MaterialTheme.typography.bodySmall)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,7 +233,7 @@ private enum class StreamIconShape { CAMERA, CHIP, GAUGE, SCREEN }
|
|||||||
|
|
||||||
/** Одна строка управления: значки отражают применённый режим, меню сохраняют все варианты выбора. */
|
/** Одна строка управления: значки отражают применённый режим, меню сохраняют все варианты выбора. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun StreamCaptureToolbar(cameras: List<StreamCamera>, config: StreamCaptureConfig?, display: Int,
|
private fun StreamCaptureToolbar(cameras: List<StreamCamera>, config: StreamCaptureConfig?, display: Int, modifier: Modifier,
|
||||||
enabled: Boolean, audio: Boolean, audioEnabled: Boolean, onAudioChange: (Boolean) -> Unit,
|
enabled: Boolean, audio: Boolean, audioEnabled: Boolean, onAudioChange: (Boolean) -> Unit,
|
||||||
onChange: (StreamCaptureConfig) -> Unit) {
|
onChange: (StreamCaptureConfig) -> Unit) {
|
||||||
val mode = config?.mode
|
val mode = config?.mode
|
||||||
@@ -250,7 +255,7 @@ private fun StreamCaptureToolbar(cameras: List<StreamCamera>, config: StreamCapt
|
|||||||
val bitrateValue = config?.let { stringResource(R.string.stream_bitrate_value, it.bitrate / 1_000_000.0) } ?: "—"
|
val bitrateValue = config?.let { stringResource(R.string.stream_bitrate_value, it.bitrate / 1_000_000.0) } ?: "—"
|
||||||
val bitrateDescription = if (config != null && config.videoBitrate == null)
|
val bitrateDescription = if (config != null && config.videoBitrate == null)
|
||||||
stringResource(R.string.stream_bitrate_auto_value, bitrateValue) else stringResource(R.string.stream_bitrate, bitrateValue)
|
stringResource(R.string.stream_bitrate_auto_value, bitrateValue) else stringResource(R.string.stream_bitrate, bitrateValue)
|
||||||
Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(4.dp),
|
Row(modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically) {
|
verticalAlignment = Alignment.CenterVertically) {
|
||||||
StreamControlButton(stringResource(if (audio) R.string.stream_audio_on else R.string.stream_audio_off),
|
StreamControlButton(stringResource(if (audio) R.string.stream_audio_on else R.string.stream_audio_off),
|
||||||
enabled = audioEnabled, checked = audio, onClick = { onAudioChange(!audio) }) {
|
enabled = audioEnabled, checked = audio, onClick = { onAudioChange(!audio) }) {
|
||||||
@@ -347,21 +352,23 @@ private fun StreamCaptureToolbar(cameras: List<StreamCamera>, config: StreamCapt
|
|||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun StreamControlButton(description: String, enabled: Boolean = true, checked: Boolean? = null,
|
private fun StreamControlButton(description: String, enabled: Boolean = true, checked: Boolean? = null,
|
||||||
onClick: () -> Unit, icon: @Composable () -> Unit) {
|
containerColor: Color? = null, onClick: () -> Unit, icon: @Composable () -> Unit) {
|
||||||
TooltipBox(positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), tooltip = { PlainTooltip { Text(description) } },
|
TooltipBox(positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), tooltip = { PlainTooltip { Text(description) } },
|
||||||
state = rememberTooltipState()) {
|
state = rememberTooltipState()) {
|
||||||
val modifier = Modifier.size(48.dp).semantics { contentDescription = description }
|
val modifier = Modifier.size(48.dp).semantics { contentDescription = description }
|
||||||
val colors = MaterialTheme.colorScheme
|
val colors = MaterialTheme.colorScheme
|
||||||
val background = colors.surfaceVariant.copy(alpha = 0.55f)
|
val background = containerColor ?: colors.surfaceVariant.copy(alpha = 0.55f)
|
||||||
|
val disabledBackground = containerColor?.copy(alpha = 0.38f) ?: colors.surfaceVariant.copy(alpha = 0.2f)
|
||||||
|
val content = if (containerColor != null) Color.White else colors.onSurface
|
||||||
val shape = RoundedCornerShape(12.dp)
|
val shape = RoundedCornerShape(12.dp)
|
||||||
if (checked == null) FilledTonalIconButton(onClick = onClick, enabled = enabled, modifier = modifier, shape = shape,
|
if (checked == null) FilledTonalIconButton(onClick = onClick, enabled = enabled, modifier = modifier, shape = shape,
|
||||||
colors = IconButtonDefaults.filledTonalIconButtonColors(containerColor = background, contentColor = colors.onSurface,
|
colors = IconButtonDefaults.filledTonalIconButtonColors(containerColor = background, contentColor = content,
|
||||||
disabledContainerColor = colors.surfaceVariant.copy(alpha = 0.2f))) {
|
disabledContainerColor = disabledBackground, disabledContentColor = content.copy(alpha = 0.38f))) {
|
||||||
Box(Modifier.clearAndSetSemantics {}) { icon() }
|
Box(Modifier.clearAndSetSemantics {}) { icon() }
|
||||||
} else FilledTonalIconToggleButton(checked = checked, onCheckedChange = { onClick() }, enabled = enabled,
|
} else FilledTonalIconToggleButton(checked = checked, onCheckedChange = { onClick() }, enabled = enabled,
|
||||||
modifier = modifier, shape = shape,
|
modifier = modifier, shape = shape,
|
||||||
colors = IconButtonDefaults.filledTonalIconToggleButtonColors(containerColor = background, contentColor = colors.onSurfaceVariant,
|
colors = IconButtonDefaults.filledTonalIconToggleButtonColors(containerColor = background, contentColor = colors.onSurfaceVariant,
|
||||||
disabledContainerColor = colors.surfaceVariant.copy(alpha = 0.2f), checkedContainerColor = colors.primaryContainer,
|
disabledContainerColor = disabledBackground, checkedContainerColor = colors.primaryContainer,
|
||||||
checkedContentColor = colors.onPrimaryContainer)) {
|
checkedContentColor = colors.onPrimaryContainer)) {
|
||||||
Box(Modifier.clearAndSetSemantics {}) { icon() }
|
Box(Modifier.clearAndSetSemantics {}) { icon() }
|
||||||
}
|
}
|
||||||
@@ -513,12 +520,17 @@ internal fun StreamDisplayDegrees(): Int {
|
|||||||
private fun CameraPreviewSurface(preview: StreamPreview, displayDegrees: Int) {
|
private fun CameraPreviewSurface(preview: StreamPreview, displayDegrees: Int) {
|
||||||
val state by preview.state.collectAsState()
|
val state by preview.state.collectAsState()
|
||||||
val aspect = state.format?.aspect(displayDegrees) ?: 9f / 16f
|
val aspect = state.format?.aspect(displayDegrees) ?: 9f / 16f
|
||||||
Column(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Text(stringResource(if (state.failed) R.string.stream_preview_failed else if (state.active) R.string.stream_live else R.string.stream_connecting))
|
Box(Modifier.aspectRatio(aspect, matchHeightConstraintsFirst = true)) {
|
||||||
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
AndroidView(factory = { CameraPreviewView(it, preview) }, modifier = Modifier.matchParentSize(),
|
||||||
Box(Modifier.aspectRatio(aspect, matchHeightConstraintsFirst = true)) {
|
update = { it.update(state.format, displayDegrees) }, onRelease = { it.close() })
|
||||||
AndroidView(factory = { CameraPreviewView(it, preview) }, modifier = Modifier.matchParentSize(),
|
}
|
||||||
update = { it.update(state.format, displayDegrees) }, onRelease = { it.close() })
|
if (state.failed || !state.active) {
|
||||||
|
Surface(Modifier.align(Alignment.TopCenter).padding(8.dp), shape = RoundedCornerShape(8.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f)) {
|
||||||
|
Text(stringResource(if (state.failed) R.string.stream_preview_failed else R.string.stream_connecting),
|
||||||
|
Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
color = if (state.failed) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user