Preserve stream capture settings and refine audio playback

This commit is contained in:
evgeny committed 2026-10-16 14:05:54 +02:00
1 parent 198ebc0acb
commit d60f8e4b1b
16 files changed
+244 -43

No files matched your search

@@ -56,6 +56,14 @@ class ConfigProvider(private val context: Context) {
private val chatOpusCodecPreset = intPreferencesKey("chat_opus_codec_preset")
private val streamSubblocks = intPreferencesKey("stream_subblocks")
private val streamRsRedundant = intPreferencesKey("stream_rs_redundant")
private val streamCamera = stringPreferencesKey("stream_capture_camera")
private val streamWidth = intPreferencesKey("stream_capture_width")
private val streamHeight = intPreferencesKey("stream_capture_height")
private val streamFps = intPreferencesKey("stream_capture_fps")
private val streamCodec = intPreferencesKey("stream_capture_codec")
private val streamRotation = intPreferencesKey("stream_capture_rotation")
private val streamBitrate = intPreferencesKey("stream_capture_bitrate")
private val streamAudio = booleanPreferencesKey("stream_capture_audio")
private val callJitterMaxReserveMs = intPreferencesKey("call_jitter_max_reserve_ms")
private val callVideoBitrateRange = stringPreferencesKey("call_video_bitrate_range_kbps")
private val chatCompressorEnabled = intPreferencesKey("chat_compressor_enabled")
@@ -166,6 +174,26 @@ class ConfigProvider(private val context: Context) {
LogManager.addLog("INFO", "STREAM", "Broadcast settings saved n=$subblocks r=$redundant; apply on next start")
}
suspend fun getStreamCaptureSettings(): StreamCaptureSettings {
val prefs = context.dataStore.data.first()
return StreamCaptureSettings(prefs[streamCamera] ?: "", prefs[streamWidth] ?: 1280, prefs[streamHeight] ?: 720,
prefs[streamFps] ?: 30, prefs[streamCodec] ?: StreamVideoCodec.H264.id, prefs[streamRotation] ?: StreamOrientation.LEFT.degrees,
prefs[streamBitrate], prefs[streamAudio] ?: true)
}
suspend fun saveStreamCaptureSettings(config: StreamCaptureConfig?, audio: Boolean?) {
context.dataStore.edit { prefs ->
config?.let {
prefs[streamCamera] = it.mode.camera
prefs[streamWidth] = it.mode.width; prefs[streamHeight] = it.mode.height; prefs[streamFps] = it.mode.fps
prefs[streamCodec] = it.mode.videoCodec.id; prefs[streamRotation] = it.orientation.degrees
if (it.videoBitrate == null) prefs.remove(streamBitrate) else prefs[streamBitrate] = it.videoBitrate
}
audio?.let { prefs[streamAudio] = it }
}
LogManager.addLog("INFO", "stream", "capture settings saved config=$config audio=$audio")
}
fun getString(key: String): String? = runBlocking {
when {
key == "node.private_key" -> readKeysFromFile()?.second ?: context.dataStore.data.first()[nodePrivateKey]
@@ -8,7 +8,7 @@ enum class StreamOrientation(val degrees: Int) {
}
/** Режим камеры и ориентация подтверждаются и восстанавливаются одним значением. */
data class StreamCaptureConfig(val mode: StreamCaptureMode, val orientation: StreamOrientation = StreamOrientation.UPRIGHT,
data class StreamCaptureConfig(val mode: StreamCaptureMode, val orientation: StreamOrientation = StreamOrientation.LEFT,
val videoBitrate: Int? = null) {
val bitrate get() = videoBitrate ?: mode.bitrate
init { require(bitrate in mode.minimumBitrate..mode.maximumBitrate) }
@@ -0,0 +1,29 @@
package com.utun.chat.data
/** Последний выбор пользователя; возможности encoder заново берём у устройства. */
data class StreamCaptureSettings(val camera: String = "", val width: Int = 1280, val height: Int = 720, val fps: Int = 30,
val codec: Int = StreamVideoCodec.H264.id, val rotation: Int = StreamOrientation.LEFT.degrees,
val bitrate: Int? = null, val audio: Boolean = true) {
fun resolve(cameras: List<StreamCamera>): StreamCaptureConfig? {
val selected = cameras.firstOrNull { it.id == camera }
?: cameras.firstOrNull { it.lens.type == LensType.MAIN } ?: cameras.firstOrNull()
if (selected == null) {
LogManager.addLog("WARN", "stream", "capture settings: no available camera"); return null
}
val modes = selected.modes.filter { it.videoCodec.id == codec }.ifEmpty { selected.modes }
val mode = modes.firstOrNull { it.width == width && it.height == height && it.fps == fps }
?: modes.filter { it.width == width && it.height == height && it.fps <= fps }.maxByOrNull { it.fps }
?: StreamCameraModes.choose(modes)
if (mode == null) {
LogManager.addLog("WARN", "stream", "capture settings: camera=${selected.id} has no available mode"); return null
}
val orientation = StreamOrientation.values().firstOrNull { it.degrees == rotation } ?: StreamOrientation.LEFT
val restored = StreamCaptureConfig(mode, orientation, bitrate?.coerceIn(mode.minimumBitrate, mode.maximumBitrate))
if (camera.isNotEmpty() && (camera != mode.camera || width != mode.width || height != mode.height || fps != mode.fps ||
codec != mode.videoCodec.id || rotation != orientation.degrees || bitrate != restored.videoBitrate))
LogManager.addLog("WARN", "stream", "capture settings adjusted: saved=$this restored=$restored")
LogManager.addLog("INFO", "stream", "capture settings restored camera=${mode.camera} mode=${mode.label} " +
"codec=${mode.videoCodec.label} rotation=${orientation.degrees} bitrate=${restored.videoBitrate ?: "auto"} audio=$audio")
return restored
}
}
@@ -7,6 +7,11 @@ import android.widget.Toast
import androidx.core.content.ContextCompat
import com.utun.chat.ChatApplication
import com.utun.chat.R
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import java.util.concurrent.atomic.AtomicLong
@@ -34,6 +39,16 @@ object StreamController {
@Volatile var capturing = false
private set
private var service: StreamService? = null
private val settingsScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
/** Запись переживает закрытие экрана; DataStore сохраняет выбор одним изменением. */
fun rememberSettings(config: StreamCaptureConfig? = null, audio: Boolean? = null) {
settingsScope.launch {
try { ChatApplication.instance.configProvider.saveStreamCaptureSettings(config, audio) }
catch (e: CancellationException) { throw e }
catch (e: Exception) { LogManager.addLog("ERROR", "stream", "capture settings save failed: $e") }
}
}
fun start(group: Long, config: StreamCaptureConfig, audio: Boolean) {
check(Looper.myLooper() == Looper.getMainLooper())
@@ -45,6 +60,7 @@ object StreamController {
mutable.value = StreamState(id, group, StreamPhase.FAILED, R.string.stream_resources_busy); return
}
capturing = true
rememberSettings(config, audio)
mutable.value = StreamState(id, group, StreamPhase.STARTING, pendingConfig = config, audioAvailable = audio, audioEnabled = audio)
mutableTx.value = null
try {
@@ -84,6 +100,7 @@ object StreamController {
val st = mutable.value ?: return@post
if (st.id != id || !st.ongoing || st.phase == StreamPhase.STOPPING) return@post
mutable.value = st.copy(config = config, pendingConfig = null, notice = if (restored) R.string.stream_mode_restored else null)
rememberSettings(config)
}
}
@@ -25,7 +25,6 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment
import androidx.compose.ui.geometry.CornerRadius
@@ -48,6 +47,7 @@ import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import com.utun.chat.R
import com.utun.chat.ChatApplication
import com.utun.chat.data.LogManager
import com.utun.chat.data.StreamController
import com.utun.chat.data.StreamPhase
@@ -57,6 +57,7 @@ import com.utun.chat.data.StreamPreviewTarget
import com.utun.chat.data.StreamSource
import com.utun.chat.data.StreamCaptureMode
import com.utun.chat.data.StreamCaptureConfig
import com.utun.chat.data.StreamCaptureSettings
import com.utun.chat.data.StreamOrientation
import com.utun.chat.data.StreamCamera
import com.utun.chat.data.StreamCameraModes
@@ -83,18 +84,22 @@ fun StreamScreen(channelId: String, channelName: String, sources: List<StreamSou
val local = state?.takeIf { it.group == group }
var previewedSession by remember(group) { mutableStateOf<Long?>(null) }
var audio by remember { mutableStateOf(true) }
var mode by remember { mutableStateOf<StreamCaptureMode?>(null) }
var orientation by rememberSaveable { mutableStateOf(StreamOrientation.UPRIGHT) }
var videoBitrate by rememberSaveable { mutableStateOf<Int?>(null) }
val config = mode?.let { StreamCaptureConfig(it, orientation, videoBitrate) }
var config by remember { mutableStateOf<StreamCaptureConfig?>(null) }
var cameras by remember { mutableStateOf<List<StreamCamera>>(emptyList()) }
var loadingModes by remember { mutableStateOf(true) }
LaunchedEffect(Unit) {
val saved = try { ChatApplication.instance.configProvider.getStreamCaptureSettings() }
catch (e: CancellationException) { throw e }
catch (e: Exception) {
LogManager.addLog("ERROR", "stream", "capture settings load failed: $e"); StreamCaptureSettings()
}
try { cameras = withContext(Dispatchers.Default) { StreamCameraModes.enumerate(context) } }
catch (e: CancellationException) { throw e }
catch (e: Exception) { LogManager.addLog("ERROR", "video", "capture modes failed: ${e.message}") }
val current = StreamController.state.value?.takeIf { it.group == group && it.ongoing }
config = current?.config ?: saved.resolve(cameras)
audio = current?.audioAvailable ?: saved.audio
loadingModes = false
mode = StreamCameraModes.choose((cameras.firstOrNull { it.lens.type == LensType.MAIN } ?: cameras.firstOrNull())?.modes.orEmpty())
}
var permissionVersion by remember { mutableStateOf(0) }
val permissions = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissionVersion++ }
@@ -108,8 +113,8 @@ fun StreamScreen(channelId: String, channelName: String, sources: List<StreamSou
}
}
LaunchedEffect(local?.config?.orientation) {
local?.config?.orientation?.let { orientation = it }
LaunchedEffect(local?.config, local?.audioAvailable) {
local?.config?.let { config = it; audio = local.audioAvailable }
}
LaunchedEffect(local?.phase, local?.pendingConfig, selected?.id) {
@@ -160,9 +165,16 @@ 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, displayDegrees) { mode = it.mode; orientation = it.orientation; videoBitrate = it.videoBitrate }
CaptureOptions(cameras, config, !loadingModes, displayDegrees) {
config = it; StreamController.rememberSettings(config = it)
}
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)) }
Row {
Checkbox(checked = audio, enabled = !loadingModes, onCheckedChange = {
audio = it; StreamController.rememberSettings(audio = it)
})
Text(stringResource(R.string.microphone), Modifier.padding(top = 12.dp))
}
Button(onClick = {
if (!cameraGranted || (audio && !micGranted)) permissions.launch(
listOf(Manifest.permission.CAMERA).plus(if (audio) listOf(Manifest.permission.RECORD_AUDIO) else emptyList()).toTypedArray())
@@ -120,7 +120,7 @@ JNIEXPORT jlong JNICALL Java_com_utun_chat_data_NativeLib_nativeStreamPlaybackCr
struct android_stream_playback *p = u_calloc(1, sizeof(*p));
if (!p) { DEBUG_ERROR(DEBUG_CATEGORY_STREAM, "JNI playback allocation failed"); return 0; }
stream_playback_clock_reset(&p->clock, (uint64_t)id); p->position = pts; p->time_us = -1;
if (audio && !(p->audio = stream_playback_audio_create(pts))) { u_free(p); return 0; }
if (audio && !(p->audio = stream_playback_audio_create(pts, 40))) { u_free(p); return 0; }
return (jlong)(intptr_t)p;
}