diff --git a/doc/tasks.md b/doc/tasks.md index b4194633..53903725 100644 --- a/doc/tasks.md +++ b/doc/tasks.md @@ -9,6 +9,9 @@ В логе получатель дважды отклонил приглашение и отправил ACK; отправитель до проверки получил только late ACK request=507. Отдельный повтор прошёл. Причина не установлена; проверить доставку UDP и шаги ускоренных часов под параллельной нагрузкой. Логи: /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: При проверке зума test_stream_view::realH264RenderingAndClose получил silenceGaps=3 вместо gapsBefore=2. diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamScreen.kt index ab3116aa..bf8e1a1f 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamScreen.kt @@ -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.VolumeUp 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.material3.* import androidx.compose.runtime.* @@ -35,6 +36,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.StrokeCap 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.rotate import androidx.compose.ui.semantics.clearAndSetSemantics @@ -145,54 +147,57 @@ fun StreamScreen(channelId: String, channelName: String, sources: List Column(Modifier.fillMaxSize().padding(padding).padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - StreamCaptureToolbar(cameras, shownConfig, displayDegrees, - enabled = if (ongoing != null) ongoing.phase == StreamPhase.LIVE && ongoing.pendingConfig == null else !loadingModes, - audio = shownAudio, - audioEnabled = if (ongoing != null) ongoing.phase == StreamPhase.LIVE && ongoing.audioAvailable else !loadingModes, - onAudioChange = { - LogManager.addLog("INFO", "stream", "screen audio group=$group enabled=$it live=${ongoing != null}") - if (ongoing != null) StreamController.setAudioEnabled(it) - else { audio = it; StreamController.rememberSettings(audio = it) } - }, onChange = { - LogManager.addLog("INFO", "stream", "screen capture selection group=$group live=${ongoing != null} 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) - if (selected.self && local?.ongoing == true) { - CaptureModeStatus(local.config, local.pendingConfig, local.notice) - StreamTxPanel(local.id) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + StreamCaptureToolbar(cameras, shownConfig, displayDegrees, Modifier.weight(1f), + enabled = if (ongoing != null) ongoing.phase == StreamPhase.LIVE && ongoing.pendingConfig == null else !loadingModes, + audio = shownAudio, + audioEnabled = if (ongoing != null) ongoing.phase == StreamPhase.LIVE && ongoing.audioAvailable else !loadingModes, + onAudioChange = { + LogManager.addLog("INFO", "stream", "screen audio group=$group enabled=$it live=${ongoing != null}") + if (ongoing != null) StreamController.setAudioEnabled(it) + else { audio = it; StreamController.rememberSettings(audio = it) } + }, onChange = { + LogManager.addLog("INFO", "stream", "screen capture selection group=$group live=${ongoing != null} config=$it") + if (ongoing != null) StreamController.change(it) + else { config = it; StreamController.rememberSettings(config = it) } + }) + val action = if (ongoing == null) R.string.stream_start + else if (ongoing.phase == StreamPhase.STOPPING) R.string.stream_stopping else R.string.stream_stop + StreamControlButton(stringResource(action), + 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()) { if (selected.self && local?.ongoing != true) Text(stringResource(R.string.stream_ended)) else key(group, selected.id, selected.epoch) { 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 { - if (local?.ongoing == true) { - 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 - 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)) } - } + if (ongoing == null && !loadingModes && cameras.isEmpty()) + Text(stringResource(R.string.stream_no_modes), 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) 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 Column { 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), - style = MaterialTheme.typography.bodySmall, minLines = 2) + style = MaterialTheme.typography.bodySmall) } } @@ -228,7 +233,7 @@ private enum class StreamIconShape { CAMERA, CHIP, GAUGE, SCREEN } /** Одна строка управления: значки отражают применённый режим, меню сохраняют все варианты выбора. */ @Composable -private fun StreamCaptureToolbar(cameras: List, config: StreamCaptureConfig?, display: Int, +private fun StreamCaptureToolbar(cameras: List, config: StreamCaptureConfig?, display: Int, modifier: Modifier, enabled: Boolean, audio: Boolean, audioEnabled: Boolean, onAudioChange: (Boolean) -> Unit, onChange: (StreamCaptureConfig) -> Unit) { val mode = config?.mode @@ -250,7 +255,7 @@ private fun StreamCaptureToolbar(cameras: List, config: StreamCapt val bitrateValue = config?.let { stringResource(R.string.stream_bitrate_value, it.bitrate / 1_000_000.0) } ?: "—" val bitrateDescription = if (config != null && config.videoBitrate == null) 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) { StreamControlButton(stringResource(if (audio) R.string.stream_audio_on else R.string.stream_audio_off), enabled = audioEnabled, checked = audio, onClick = { onAudioChange(!audio) }) { @@ -347,21 +352,23 @@ private fun StreamCaptureToolbar(cameras: List, config: StreamCapt @OptIn(ExperimentalMaterial3Api::class) @Composable 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) } }, state = rememberTooltipState()) { val modifier = Modifier.size(48.dp).semantics { contentDescription = description } 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) if (checked == null) FilledTonalIconButton(onClick = onClick, enabled = enabled, modifier = modifier, shape = shape, - colors = IconButtonDefaults.filledTonalIconButtonColors(containerColor = background, contentColor = colors.onSurface, - disabledContainerColor = colors.surfaceVariant.copy(alpha = 0.2f))) { + colors = IconButtonDefaults.filledTonalIconButtonColors(containerColor = background, contentColor = content, + disabledContainerColor = disabledBackground, disabledContentColor = content.copy(alpha = 0.38f))) { Box(Modifier.clearAndSetSemantics {}) { icon() } } else FilledTonalIconToggleButton(checked = checked, onCheckedChange = { onClick() }, enabled = enabled, modifier = modifier, shape = shape, 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)) { Box(Modifier.clearAndSetSemantics {}) { icon() } } @@ -513,12 +520,17 @@ internal fun StreamDisplayDegrees(): Int { private fun CameraPreviewSurface(preview: StreamPreview, displayDegrees: Int) { val state by preview.state.collectAsState() val aspect = state.format?.aspect(displayDegrees) ?: 9f / 16f - Column(Modifier.fillMaxSize()) { - 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.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { - Box(Modifier.aspectRatio(aspect, matchHeightConstraintsFirst = true)) { - AndroidView(factory = { CameraPreviewView(it, preview) }, modifier = Modifier.matchParentSize(), - update = { it.update(state.format, displayDegrees) }, onRelease = { it.close() }) + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Box(Modifier.aspectRatio(aspect, matchHeightConstraintsFirst = true)) { + 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) } } }