Preserve stream capture settings and refine audio playback
This commit is contained in:
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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user