From 0346363b98adb8e969ba6c4ff9e9a0f69706f5f3 Mon Sep 17 00:00:00 2001 From: kingdo10 <275526951+kingdo10@users.noreply.github.com> Date: Mon, 1 Jun 2026 11:22:29 +0100 Subject: [PATCH] Local Mode - MP4 | Audio only mode --- .../ui/tabs/stream/StreamTabContent.kt | 6 +- .../common/module/StreamingModule.kt | 4 + mjpeg/src/main/AndroidManifest.xml | 6 +- mjpeg/src/main/assets/index.html | 227 ++++++++- .../screenstream/mjpeg/MjpegKoinModule.kt | 2 +- .../mjpeg/MjpegStreamingModule.kt | 18 +- .../screenstream/mjpeg/internal/HttpServer.kt | 157 +++++- .../mjpeg/internal/HttpServerData.kt | 9 +- .../mjpeg/internal/MjpegStreamingService.kt | 412 ++++++++++++++- .../mjpeg/internal/audio/MjpegAudioCapture.kt | 362 +++++++++++++ .../mjpeg/internal/audio/MjpegAudioEncoder.kt | 295 +++++++++++ .../internal/audio/MjpegAudioEncoderUtils.kt | 91 ++++ .../mjpeg/internal/audio/MjpegAudioModels.kt | 27 + .../mjpeg/internal/audio/MjpegAudioSource.kt | 48 ++ .../mjpeg/internal/audio/MjpegAudioSources.kt | 260 ++++++++++ .../mjpeg/internal/audio/MjpegMasterClock.kt | 31 ++ .../mjpeg/internal/audio/OggOpusMuxer.kt | 97 ++++ .../mjpeg/internal/mp4/Mp4AudioEncoder.kt | 306 +++++++++++ .../internal/mp4/Mp4AudioEncoderUtils.kt | 67 +++ .../mjpeg/internal/mp4/Mp4BoxWriter.kt | 475 ++++++++++++++++++ .../mjpeg/internal/mp4/Mp4Capture.kt | 214 ++++++++ .../mjpeg/internal/mp4/Mp4EglRenderer.kt | 375 ++++++++++++++ .../mjpeg/internal/mp4/Mp4Models.kt | 93 ++++ .../mjpeg/internal/mp4/Mp4VideoEncoder.kt | 354 +++++++++++++ .../internal/mp4/Mp4VideoEncoderUtils.kt | 189 +++++++ .../mjpeg/settings/MjpegSettings.kt | 52 ++ .../mjpeg/settings/MjpegSettingsImpl.kt | 58 +++ .../mjpeg/ui/MjpegMainScreenUI.kt | 21 + .../mjpeg/ui/main/cards/AudioSettingsCard.kt | 413 +++++++++++++++ .../ui/main/cards/GeneralSettingsCard.kt | 29 ++ .../mjpeg/ui/main/cards/ImageSettingsCard.kt | 222 +++++--- .../ui/main/settings/general/AudioOnly.kt | 25 + .../ui/main/settings/general/StreamFormat.kt | 54 ++ .../ui/main/settings/image/VideoEncoding.kt | 139 +++++ mjpeg/src/main/res/drawable/mic_24px.xml | 10 + mjpeg/src/main/res/drawable/mic_off_24px.xml | 10 + .../main/res/drawable/mobile_speaker_24px.xml | 10 + .../src/main/res/drawable/volume_off_24px.xml | 10 + mjpeg/src/main/res/values-af/strings.xml | 35 +- mjpeg/src/main/res/values-am/strings.xml | 35 +- mjpeg/src/main/res/values-ar/strings.xml | 35 +- mjpeg/src/main/res/values-bn/strings.xml | 35 +- mjpeg/src/main/res/values-de/strings.xml | 35 +- mjpeg/src/main/res/values-es/strings.xml | 35 +- mjpeg/src/main/res/values-eu/strings.xml | 35 +- mjpeg/src/main/res/values-fr/strings.xml | 35 +- mjpeg/src/main/res/values-hi/strings.xml | 35 +- mjpeg/src/main/res/values-in/strings.xml | 35 +- mjpeg/src/main/res/values-it/strings.xml | 35 +- mjpeg/src/main/res/values-ja/strings.xml | 35 +- mjpeg/src/main/res/values-jv/strings.xml | 35 +- mjpeg/src/main/res/values-ka/strings.xml | 35 +- mjpeg/src/main/res/values-nl/strings.xml | 35 +- mjpeg/src/main/res/values-pl/strings.xml | 35 +- mjpeg/src/main/res/values-pt/strings.xml | 35 +- mjpeg/src/main/res/values-ru/strings.xml | 35 +- mjpeg/src/main/res/values-tr/strings.xml | 35 +- mjpeg/src/main/res/values-uk/strings.xml | 35 +- mjpeg/src/main/res/values-ur/strings.xml | 35 +- mjpeg/src/main/res/values-uz/strings.xml | 35 +- mjpeg/src/main/res/values-zh-rTW/strings.xml | 35 +- mjpeg/src/main/res/values-zh/strings.xml | 35 +- mjpeg/src/main/res/values/strings.xml | 31 +- .../screenstream/rtsp/RtspStreamingModule.kt | 1 + .../rtsp/internal/RtspStreamingService.kt | 461 ++++++++++++----- .../rtsp/internal/audio/AudioEncoder.kt | 14 +- .../rtsp/internal/rtsp/RtcpReporter.kt | 6 +- .../rtsp/internal/rtsp/core/SdpBuilder.kt | 20 +- .../rtsp/server/RtspServerConnection.kt | 18 +- .../rtsp/server/RtspServerMessageHandler.kt | 2 +- .../rtsp/settings/RtspSettings.kt | 3 + .../rtsp/settings/RtspSettingsImpl.kt | 4 + .../screenstream/rtsp/ui/RtspMainScreenUI.kt | 6 + .../rtsp/ui/main/cards/ServerSettingsCard.kt | 10 + .../rtsp/ui/main/settings/server/AudioOnly.kt | 22 + rtsp/src/main/res/values-af/strings.xml | 3 + rtsp/src/main/res/values-am/strings.xml | 3 + rtsp/src/main/res/values-ar/strings.xml | 3 + rtsp/src/main/res/values-bn/strings.xml | 3 + rtsp/src/main/res/values-de/strings.xml | 3 + rtsp/src/main/res/values-es/strings.xml | 3 + rtsp/src/main/res/values-eu/strings.xml | 3 + rtsp/src/main/res/values-fr/strings.xml | 3 + rtsp/src/main/res/values-hi/strings.xml | 3 + rtsp/src/main/res/values-in/strings.xml | 3 + rtsp/src/main/res/values-it/strings.xml | 3 + rtsp/src/main/res/values-ja/strings.xml | 3 + rtsp/src/main/res/values-jv/strings.xml | 3 + rtsp/src/main/res/values-ka/strings.xml | 3 + rtsp/src/main/res/values-nl/strings.xml | 3 + rtsp/src/main/res/values-pl/strings.xml | 3 + rtsp/src/main/res/values-pt/strings.xml | 3 + rtsp/src/main/res/values-ru/strings.xml | 3 + rtsp/src/main/res/values-tr/strings.xml | 3 + rtsp/src/main/res/values-uk/strings.xml | 3 + rtsp/src/main/res/values-ur/strings.xml | 3 + rtsp/src/main/res/values-uz/strings.xml | 3 + rtsp/src/main/res/values-zh-rTW/strings.xml | 3 + rtsp/src/main/res/values-zh/strings.xml | 3 + rtsp/src/main/res/values/strings.xml | 2 + 100 files changed, 6395 insertions(+), 295 deletions(-) create mode 100644 mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/audio/MjpegAudioCapture.kt create mode 100644 mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/audio/MjpegAudioEncoder.kt create mode 100644 mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/audio/MjpegAudioEncoderUtils.kt create mode 100644 mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/audio/MjpegAudioModels.kt create mode 100644 mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/audio/MjpegAudioSource.kt create mode 100644 mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/audio/MjpegAudioSources.kt create mode 100644 mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/audio/MjpegMasterClock.kt create mode 100644 mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/audio/OggOpusMuxer.kt create mode 100644 mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/mp4/Mp4AudioEncoder.kt create mode 100644 mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/mp4/Mp4AudioEncoderUtils.kt create mode 100644 mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/mp4/Mp4BoxWriter.kt create mode 100644 mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/mp4/Mp4Capture.kt create mode 100644 mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/mp4/Mp4EglRenderer.kt create mode 100644 mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/mp4/Mp4Models.kt create mode 100644 mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/mp4/Mp4VideoEncoder.kt create mode 100644 mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/mp4/Mp4VideoEncoderUtils.kt create mode 100644 mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/ui/main/cards/AudioSettingsCard.kt create mode 100644 mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/ui/main/settings/general/AudioOnly.kt create mode 100644 mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/ui/main/settings/general/StreamFormat.kt create mode 100644 mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/ui/main/settings/image/VideoEncoding.kt create mode 100644 mjpeg/src/main/res/drawable/mic_24px.xml create mode 100644 mjpeg/src/main/res/drawable/mic_off_24px.xml create mode 100644 mjpeg/src/main/res/drawable/mobile_speaker_24px.xml create mode 100644 mjpeg/src/main/res/drawable/volume_off_24px.xml create mode 100644 rtsp/src/main/java/info/dvkr/screenstream/rtsp/ui/main/settings/server/AudioOnly.kt diff --git a/app/src/main/java/info/dvkr/screenstream/ui/tabs/stream/StreamTabContent.kt b/app/src/main/java/info/dvkr/screenstream/ui/tabs/stream/StreamTabContent.kt index 08d06b2d..47a2253c 100644 --- a/app/src/main/java/info/dvkr/screenstream/ui/tabs/stream/StreamTabContent.kt +++ b/app/src/main/java/info/dvkr/screenstream/ui/tabs/stream/StreamTabContent.kt @@ -142,6 +142,8 @@ private fun ModuleSelectorRow( onModuleSelect: (StreamingModule.Id) -> Unit, modifier: Modifier = Modifier ) { + val moduleName = module.name() + Row( modifier = modifier.selectable( selected = module.id == selectedModuleId, @@ -155,7 +157,7 @@ private fun ModuleSelectorRow( RadioButton(selected = module.id == selectedModuleId, onClick = null, modifier = Modifier.padding(start = 8.dp)) Text( - text = stringResource(id = module.nameResource), + text = moduleName, modifier = Modifier .padding(start = 16.dp) .weight(1F), @@ -180,7 +182,7 @@ private fun ModuleSelectorRow( }, title = { Text( - text = stringResource(id = module.nameResource), + text = moduleName, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center ) diff --git a/common/src/main/java/info/dvkr/screenstream/common/module/StreamingModule.kt b/common/src/main/java/info/dvkr/screenstream/common/module/StreamingModule.kt index 551177e2..c1eb598d 100644 --- a/common/src/main/java/info/dvkr/screenstream/common/module/StreamingModule.kt +++ b/common/src/main/java/info/dvkr/screenstream/common/module/StreamingModule.kt @@ -8,6 +8,7 @@ import androidx.annotation.StringRes import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import kotlinx.coroutines.flow.Flow import org.koin.core.scope.Scope @@ -50,6 +51,9 @@ public interface StreamingModule { @get:StringRes public val detailsResource: Int + @Composable + public fun name(): String = stringResource(nameResource) + @Composable public fun StreamUIContent(windowWidthSizeClass: WindowWidthSizeClass, modifier: Modifier) diff --git a/mjpeg/src/main/AndroidManifest.xml b/mjpeg/src/main/AndroidManifest.xml index a1333a13..5ef3799d 100644 --- a/mjpeg/src/main/AndroidManifest.xml +++ b/mjpeg/src/main/AndroidManifest.xml @@ -5,6 +5,8 @@ + + @@ -16,6 +18,6 @@ android:name=".MjpegModuleService" android:enabled="true" android:exported="false" - android:foregroundServiceType="mediaProjection" /> + android:foregroundServiceType="mediaProjection|microphone" /> - \ No newline at end of file + diff --git a/mjpeg/src/main/assets/index.html b/mjpeg/src/main/assets/index.html index 2478513d..2a16048b 100644 --- a/mjpeg/src/main/assets/index.html +++ b/mjpeg/src/main/assets/index.html @@ -67,7 +67,8 @@ filter: drop-shadow(3px 3px 2px rgba(0, 0, 0, .4)); } - #streamDiv img { + #streamDiv img, + #streamDiv video { position: absolute; top: 0; bottom: 0; @@ -80,6 +81,21 @@ max-width: 100%; } + #streamDiv video { + display: none; + background: #000; + } + + #audioStream { + position: absolute; + left: 50%; + bottom: 16px; + transform: translateX(-50%); + z-index: 10; + display: none; + max-width: calc(100% - 32px); + } + #buttonsDiv { background-color: #08121C; visibility: hidden; @@ -237,7 +253,8 @@ -
+
+
@@ -278,6 +295,8 @@ var buttonPiP = document.getElementById("PiP"); var streamDiv = document.getElementById("streamDiv"); var stream = document.getElementById("stream"); + var videoStream = document.getElementById("videoStream"); + var audioStream = document.getElementById("audioStream"); var connectDiv = document.getElementById("connectDiv"); var reconnectDiv = document.getElementById("reconnectDiv"); var pinDiv = document.getElementById("pinDiv"); @@ -290,6 +309,48 @@ var keepImageOnReconnect = document.body.dataset.keepImageOnReconnect === "true"; var hasStreamImage = false; var isReconnecting = false; + var activeAudioUrl = null; + var activeVideoUrl = null; + var pendingAudioAutoplay = null; + var pendingVideoAutoplay = null; + var audioRetryInProgress = false; + var videoRetryInProgress = false; + var currentStreamFormat = "MJPEG"; + + function retryPendingVideoAutoplay() { + if (pendingVideoAutoplay && videoRetryInProgress === false) { + var retry = pendingVideoAutoplay; + pendingVideoAutoplay = null; + videoRetryInProgress = true; + setVideoSource(retry.url); + playVideo(); + videoRetryInProgress = false; + } + } + + function retryPendingAudioAutoplay() { + if (pendingAudioAutoplay && audioRetryInProgress === false) { + var retry = pendingAudioAutoplay; + pendingAudioAutoplay = null; + audioRetryInProgress = true; + setAudioSource(retry.url); + playAudio(retry.visibleControls); + audioRetryInProgress = false; + } + } + + ["pointerdown", "keydown"].forEach(function (eventName) { + document.addEventListener(eventName, retryPendingVideoAutoplay, { passive: true, capture: true }); + document.addEventListener(eventName, retryPendingAudioAutoplay, { passive: true, capture: true }); + }); + + videoStream.addEventListener("playing", function () { + pendingVideoAutoplay = null; + }); + + audioStream.addEventListener("playing", function () { + pendingAudioAutoplay = null; + }); var enableButtons = false; var buttonsHideFunction = function buttonsHideFunction() { @@ -312,9 +373,13 @@ if (enable) { stream.style.width = "100%"; stream.style.objectFit = "contain"; + videoStream.style.width = "100%"; + videoStream.style.objectFit = "contain"; } else { stream.style.width = null; stream.style.objectFit = null; + videoStream.style.width = null; + videoStream.style.objectFit = null; } } @@ -396,6 +461,7 @@ streamDiv.style.visibility = "hidden"; errorDiv.style.visibility = "hidden"; stream.src = ""; + stopVideo(); websocket = new WebsocketHeartbeat("ws://" + window.location.host + "/socket?clientId=" + clientId); @@ -421,6 +487,7 @@ connectDiv.style.visibility = "visible"; streamDiv.style.visibility = "hidden"; stream.src = ""; + stopVideo(); hideReconnectBar(); } MJPEGErrorCounter = 0; @@ -430,6 +497,8 @@ if (document.pictureInPictureElement) { document.exitPictureInPicture(); } + stopAudio(); + stopVideo(); }; websocket.onmessage = function (msg) { @@ -443,7 +512,19 @@ blockedDiv.style.visibility = "hidden"; pinWrongMsg.style.visibility = "inherit"; isReconnecting = false; - showStream(message.data.streamAddress + ("?clientId=" + clientId)); + if (message.data.streamAddress) { + currentStreamFormat = message.data.streamFormat || "MJPEG"; + showStream(message.data.streamAddress + ("?clientId=" + clientId), currentStreamFormat, message.data.audioOnly); + } else { + stream.src = ""; + stopVideo(); + streamDiv.style.visibility = "hidden"; + } + if (message.data.audioAddress) { + showAudio(message.data.audioAddress + ("?clientId=" + clientId), message.data.audioOnly); + } else { + stopAudio(); + } configureButtons(message.data.enableButtons); hideReconnectBar(); return; @@ -491,7 +572,12 @@ }; } - function showStream(url) { + function showStream(url, streamFormat, audioOnly) { + if (streamFormat === "MP4") { + showVideoStream(url, audioOnly); + return; + } + stopVideo(); if (!(keepImageOnReconnect && hasStreamImage && isReconnecting)) { streamDiv.style.visibility = "hidden"; stream.src = ""; @@ -533,9 +619,140 @@ }); } + function showVideoStream(url, audioOnly) { + stream.onload = null; + stream.onerror = null; + stream.src = ""; + stream.style.display = "none"; + stopAudio(); + + if (!(keepImageOnReconnect && hasStreamImage && isReconnecting)) { + streamDiv.style.visibility = "hidden"; + stopVideo(); + } + errorDiv.style.visibility = "hidden"; + clearTimeout(showStreamTimeoutId); + + setVideoSource(url); + playVideo(); + } + + function setVideoSource(url) { + activeVideoUrl = url; + videoStream.src = url + (url.indexOf("?") === -1 ? "?" : "&") + "videoStart=" + Date.now(); + videoStream.load(); + } + + function clearVideoElementSource() { + videoStream.pause(); + videoStream.removeAttribute("src"); + videoStream.load(); + } + + function playVideo() { + if (!videoStream.src && activeVideoUrl) { + setVideoSource(activeVideoUrl); + } + videoStream.muted = false; + videoStream.controls = true; + videoStream.style.display = "block"; + + var playPromise = videoStream.play(); + if (playPromise) { + playPromise.then(function () { + hasStreamImage = true; + streamDiv.style.visibility = "visible"; + hideReconnectBar(); + })["catch"](function () { + pendingVideoAutoplay = { url: activeVideoUrl }; + clearVideoElementSource(); + videoStream.controls = true; + videoStream.style.display = "block"; + hasStreamImage = true; + streamDiv.style.visibility = "visible"; + hideReconnectBar(); + }); + } else { + hasStreamImage = true; + streamDiv.style.visibility = "visible"; + hideReconnectBar(); + } + } + + function showAudio(url, visibleControls) { + if (activeAudioUrl !== url) { + setAudioSource(url); + } + playAudio(visibleControls); + } + + function setAudioSource(url) { + activeAudioUrl = url; + audioStream.src = url + (url.indexOf("?") === -1 ? "?" : "&") + "audioStart=" + Date.now(); + audioStream.load(); + } + + function clearAudioElementSource() { + audioStream.pause(); + audioStream.removeAttribute("src"); + audioStream.load(); + } + + function playAudio(visibleControls) { + if (!audioStream.src && activeAudioUrl) { + setAudioSource(activeAudioUrl); + } + audioStream.autoplay = true; + audioStream.muted = false; + audioStream.controls = visibleControls; + audioStream.style.display = visibleControls ? "block" : "none"; + + var playPromise = audioStream.play(); + if (playPromise) { + playPromise.then(function () { + pendingAudioAutoplay = null; + audioStream.controls = visibleControls; + audioStream.style.display = visibleControls ? "block" : "none"; + })["catch"](function () { + pendingAudioAutoplay = { url: activeAudioUrl, visibleControls: visibleControls }; + audioStream.pause(); + audioStream.controls = true; + audioStream.style.display = "block"; + }); + } + } + + function stopAudio() { + activeAudioUrl = null; + pendingAudioAutoplay = null; + clearAudioElementSource(); + audioStream.controls = true; + audioStream.style.display = "none"; + } + + function stopVideo() { + activeVideoUrl = null; + pendingVideoAutoplay = null; + clearVideoElementSource(); + videoStream.pause(); + videoStream.style.display = "none"; + stream.style.display = ""; + } + var drawTimeoutId = null; function togglePiP() { + if (currentStreamFormat === "MP4") { + if (document.pictureInPictureElement) { + document.exitPictureInPicture(); + } else { + videoStream.requestPictureInPicture()["catch"](function (error) { + window.DD_LOGS && DD_LOGS.logger.error("PiP.requestPictureInPicture:", { message: error }); + buttonPiP.style.display = "none"; + }); + } + return; + } if (document.pictureInPictureElement) { document.exitPictureInPicture(); } else { @@ -766,4 +983,4 @@ - \ No newline at end of file + diff --git a/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/MjpegKoinModule.kt b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/MjpegKoinModule.kt index f6645263..82eafff6 100644 --- a/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/MjpegKoinModule.kt +++ b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/MjpegKoinModule.kt @@ -20,7 +20,7 @@ public class MjpegKoinScope : KoinScopeComponent { internal val MjpegKoinQualifier: Qualifier = StringQualifier("MjpegStreamingModule") public val MjpegKoinModule: org.koin.core.module.Module = module { - single(MjpegKoinQualifier) { MjpegStreamingModule() } bind (StreamingModule::class) + single(MjpegKoinQualifier) { MjpegStreamingModule(get()) } bind (StreamingModule::class) single { MjpegSettingsImpl(context = get()) } bind (MjpegSettings::class) scope { scoped { NetworkHelper(get()) } diff --git a/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/MjpegStreamingModule.kt b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/MjpegStreamingModule.kt index 915122ac..17d66ad4 100644 --- a/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/MjpegStreamingModule.kt +++ b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/MjpegStreamingModule.kt @@ -7,13 +7,17 @@ import android.content.Intent import android.os.Looper import androidx.annotation.MainThread import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.elvishew.xlog.XLog import info.dvkr.screenstream.common.getLog import info.dvkr.screenstream.common.module.StreamingModule import info.dvkr.screenstream.common.module.isStreamingModuleStartBlocked import info.dvkr.screenstream.mjpeg.internal.MjpegEvent import info.dvkr.screenstream.mjpeg.internal.MjpegStreamingService +import info.dvkr.screenstream.mjpeg.settings.MjpegSettings import info.dvkr.screenstream.mjpeg.ui.MjpegMainScreenUI import info.dvkr.screenstream.mjpeg.ui.MjpegState import kotlinx.coroutines.NonCancellable @@ -27,7 +31,7 @@ import org.koin.core.parameter.parametersOf import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid -public class MjpegStreamingModule : StreamingModule { +public class MjpegStreamingModule(private val mjpegSettings: MjpegSettings) : StreamingModule { public companion object { public val Id: StreamingModule.Id = StreamingModule.Id("MJPEG") @@ -58,6 +62,17 @@ public class MjpegStreamingModule : StreamingModule { override val descriptionResource: Int = R.string.mjpeg_stream_mode_description override val detailsResource: Int = R.string.mjpeg_stream_mode_details + @Composable + override fun name(): String { + val settings by mjpegSettings.data.collectAsStateWithLifecycle() + val streamFormatResource = if (settings.streamFormat == MjpegSettings.Values.STREAM_FORMAT_MP4) { + R.string.mjpeg_pref_stream_format_mp4 + } else { + R.string.mjpeg_pref_stream_format_mjpeg + } + return stringResource(nameResource, stringResource(streamFormatResource)) + } + @Composable override fun StreamUIContent( windowWidthSizeClass: StreamingModule.WindowWidthSizeClass, @@ -223,6 +238,7 @@ public class MjpegStreamingModule : StreamingModule { is MjpegEvent.CastPermissionsDenied, is MjpegEvent.StartProjection, is MjpegEvent.Intentable.StopStream, + is MjpegStreamingService.InternalEvent.StartMicrophoneAudioOnlyStream, is MjpegStreamingService.InternalEvent.StartStream -> XLog.i(getLog("sendEvent", "Ignoring stale event $event in state $state")) diff --git a/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/HttpServer.kt b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/HttpServer.kt index a7c8b9f8..05e60d55 100644 --- a/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/HttpServer.kt +++ b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/HttpServer.kt @@ -9,6 +9,12 @@ import info.dvkr.screenstream.common.getVersionName import info.dvkr.screenstream.common.randomString import info.dvkr.screenstream.mjpeg.R import info.dvkr.screenstream.mjpeg.internal.HttpServerData.Companion.getClientId +import info.dvkr.screenstream.mjpeg.internal.audio.MjpegAudioPacket +import info.dvkr.screenstream.mjpeg.internal.audio.OggOpusMuxer +import info.dvkr.screenstream.mjpeg.internal.mp4.Mp4AudioPacket +import info.dvkr.screenstream.mjpeg.internal.mp4.Mp4BoxWriter +import info.dvkr.screenstream.mjpeg.internal.mp4.Mp4StreamConfig +import info.dvkr.screenstream.mjpeg.internal.mp4.Mp4VideoPacket import info.dvkr.screenstream.mjpeg.settings.MjpegSettings import info.dvkr.screenstream.mjpeg.ui.MjpegError import io.ktor.http.CacheControl @@ -71,11 +77,13 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart @@ -99,6 +107,10 @@ internal class HttpServer( context: Context, private val mjpegSettings: MjpegSettings, private val bitmapStateFlow: StateFlow, + private val audioPacketFlow: SharedFlow, + private val mp4ConfigFlow: StateFlow, + private val mp4VideoPacketFlow: SharedFlow, + private val mp4AudioPacketFlow: SharedFlow, private val sendEvent: (MjpegEvent) -> Unit ) { private val debuggable = context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0 @@ -136,6 +148,21 @@ internal class HttpServer( val keepImageOnReconnect: Boolean ) + private sealed class Mp4Sample { + abstract val generation: Long + abstract val timestampUs: Long + + data class Video(val packet: Mp4VideoPacket) : Mp4Sample() { + override val generation: Long = packet.generation + override val timestampUs: Long = packet.timestampUs + } + + data class Audio(val packet: Mp4AudioPacket) : Mp4Sample() { + override val generation: Long = packet.generation + override val timestampUs: Long = packet.timestampUs + } + } + init { XLog.d(getLog("init")) } @@ -369,6 +396,64 @@ internal class HttpServer( } } + if (serverData.audioAddress.isNotEmpty()) { + get(serverData.audioAddress) { + val clientId = call.request.getClientId() + val remoteAddress = call.request.origin.remoteAddress + val remotePort = call.request.origin.remotePort + + if (serverData.isClientAllowed(clientId, remoteAddress).not()) { + call.respond(HttpStatusCode.Forbidden) + return@get + } + + fun stopClientStream(channel: ByteWriteChannel) = channel.isClosedForWrite || serverData.isAddressBlocked(remoteAddress) || + serverData.isDisconnected(clientId, remoteAddress, remotePort) + + call.respond(object : OutgoingContent.WriteChannelContent() { + override val status: HttpStatusCode = HttpStatusCode.OK + override val contentType: ContentType = ContentType.parse("audio/ogg; codecs=opus") + + override suspend fun writeTo(channel: ByteWriteChannel) { + var currentGeneration = -1L + var muxer: OggOpusMuxer? = null + + audioPacketFlow + .onStart { + XLog.i(this@appModule.getLog("audio.onStart", "Client: $clientId:$remotePort")) + serverData.addConnected(clientId, remoteAddress, remotePort) + } + .onCompletion { + XLog.i(this@appModule.getLog("audio.onCompletion", "Client: $clientId:$remotePort")) + serverData.setDisconnected(clientId, remoteAddress, remotePort) + } + .takeWhile { stopClientStream(channel).not() } + .onEach { packet -> + if (stopClientStream(channel)) return@onEach + + if (packet.generation != currentGeneration) { + muxer?.eosPage()?.let { page -> channel.writeFully(page) } + currentGeneration = packet.generation + val newMuxer = OggOpusMuxer(channelCount = 2) + muxer = newMuxer + newMuxer.headerPages().forEach { page -> + channel.writeFully(page) + serverData.setNextBytes(clientId, remoteAddress, remotePort, page.size) + } + } + + val page = muxer!!.audioPage(packet.data, packet.durationSamples) + channel.writeFully(page) + channel.flush() + serverData.setNextBytes(clientId, remoteAddress, remotePort, page.size) + } + .catch { /* Empty intentionally */ } + .collect() + } + }) + } + } + webSocket("/socket") { val clientId = call.request.getClientId() val remoteAddress = call.request.origin.remoteAddress @@ -379,8 +464,17 @@ internal class HttpServer( frame as? Frame.Text ?: continue val msg = runCatching { JSONObject(frame.readText()) }.getOrNull() ?: continue - val enableButtons = mjpegSettings.data.value.htmlEnableButtons && serverData.enablePin.not() - val streamData = JSONObject().put("enableButtons", enableButtons).put("streamAddress", serverData.streamAddress) + val settings = mjpegSettings.data.value + val enableButtons = settings.htmlEnableButtons && serverData.enablePin.not() + val mp4Selected = settings.streamFormat == MjpegSettings.Values.STREAM_FORMAT_MP4 + val streamFormat = if (mp4Selected) "MP4" else "MJPEG" + val streamAudioOnly = mp4Selected && settings.streamAudioOnly + val streamData = JSONObject() + .put("enableButtons", enableButtons) + .put("streamAddress", serverData.streamAddress) + .put("audioAddress", JSONObject.NULL) + .put("audioOnly", streamAudioOnly) + .put("streamFormat", streamFormat) when (val type = msg.optString("type").uppercase()) { "HEARTBEAT" -> send("HEARTBEAT", msg.optString("data")) @@ -426,6 +520,65 @@ internal class HttpServer( fun stopClientStream(channel: ByteWriteChannel) = channel.isClosedForWrite || serverData.isAddressBlocked(remoteAddress) || serverData.isDisconnected(clientId, remoteAddress, remotePort) + if (serverData.streamFormat == MjpegSettings.Values.STREAM_FORMAT_MP4) { + call.respond(object : OutgoingContent.WriteChannelContent() { + override val status: HttpStatusCode = HttpStatusCode.OK + override val contentType: ContentType = ContentType.parse("video/mp4") + + override suspend fun writeTo(channel: ByteWriteChannel) { + var sequence = 1 + XLog.i(this@appModule.getLog("mp4.onStart", "Client: $clientId:$remotePort")) + serverData.addConnected(clientId, remoteAddress, remotePort) + + try { + val config = mp4ConfigFlow + .filter { it != null } + .map { requireNotNull(it) } + .takeWhile { stopClientStream(channel).not() } + .firstOrNull() ?: return + + val initSegment = Mp4BoxWriter.initSegment(config) + channel.writeFully(initSegment) + channel.flush() + serverData.setNextBytes(clientId, remoteAddress, remotePort, initSegment.size) + + val samples = merge( + mp4VideoPacketFlow.map { Mp4Sample.Video(it) }, + mp4AudioPacketFlow.map { Mp4Sample.Audio(it) } + ) + var timestampOffsetUs: Long? = null + var videoStarted = config.video == null + + samples + .takeWhile { sample -> sample.generation == config.generation && stopClientStream(channel).not() } + .onEach { sample -> + if (stopClientStream(channel)) return@onEach + if (!videoStarted) { + val videoPacket = (sample as? Mp4Sample.Video)?.packet + if (videoPacket?.isKeyFrame != true) return@onEach + timestampOffsetUs = videoPacket.timestampUs + videoStarted = true + } + val offsetUs = timestampOffsetUs ?: sample.timestampUs.also { timestampOffsetUs = it } + val segment = when (sample) { + is Mp4Sample.Video -> Mp4BoxWriter.videoSegment(sequence++, sample.packet, offsetUs) + is Mp4Sample.Audio -> Mp4BoxWriter.audioSegment(sequence++, sample.packet, offsetUs) + } + channel.writeFully(segment) + channel.flush() + serverData.setNextBytes(clientId, remoteAddress, remotePort, segment.size) + } + .catch { /* Empty intentionally */ } + .collect() + } finally { + XLog.i(this@appModule.getLog("mp4.onCompletion", "Client: $clientId:$remotePort")) + serverData.setDisconnected(clientId, remoteAddress, remotePort) + } + } + }) + return@get + } + call.respond(object : OutgoingContent.WriteChannelContent() { override val status: HttpStatusCode = HttpStatusCode.OK diff --git a/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/HttpServerData.kt b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/HttpServerData.kt index f85c13eb..6c6a6175 100644 --- a/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/HttpServerData.kt +++ b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/HttpServerData.kt @@ -128,7 +128,9 @@ internal class HttpServerData(private val sendEvent: (MjpegEvent) -> Unit) { @Volatile internal var enablePin: Boolean = false @Volatile internal var pin: String = "" @Volatile internal var blockAddress: Boolean = false + @Volatile internal var streamFormat: Int = MjpegSettings.Values.STREAM_FORMAT_MJPEG @Volatile internal var streamAddress: String = "" + @Volatile internal var audioAddress: String = "" @Volatile internal var jpegFallbackAddress: String = "" private val statisticScope = CoroutineScope(Job() + Dispatchers.Default) @@ -145,8 +147,11 @@ internal class HttpServerData(private val sendEvent: (MjpegEvent) -> Unit) { enablePin = mjpegSettings.data.value.enablePin pin = mjpegSettings.data.value.pin blockAddress = mjpegSettings.data.value.blockAddress + streamFormat = if (mjpegSettings.data.value.streamFormat == MjpegSettings.Values.STREAM_FORMAT_MP4) + MjpegSettings.Values.STREAM_FORMAT_MP4 else MjpegSettings.Values.STREAM_FORMAT_MJPEG val streamAddressBase = if (enablePin) randomString(16) else "stream" - streamAddress = "$streamAddressBase.mjpeg" + streamAddress = if (streamFormat == MjpegSettings.Values.STREAM_FORMAT_MP4) "$streamAddressBase.mp4" else "$streamAddressBase.mjpeg" + audioAddress = if (streamFormat == MjpegSettings.Values.STREAM_FORMAT_MP4) "" else "$streamAddressBase.ogg" jpegFallbackAddress = "$streamAddressBase.jpeg" } @@ -254,4 +259,4 @@ internal class HttpServerData(private val sendEvent: (MjpegEvent) -> Unit) { } private fun Long.bytesToMbit(): Float = (this * 8).toFloat() / 1024 / 1024 -} \ No newline at end of file +} diff --git a/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/MjpegStreamingService.kt b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/MjpegStreamingService.kt index 4b88bd53..297c7ea2 100644 --- a/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/MjpegStreamingService.kt +++ b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/MjpegStreamingService.kt @@ -1,9 +1,14 @@ package info.dvkr.screenstream.mjpeg.internal import android.annotation.SuppressLint +import android.Manifest +import android.app.ForegroundServiceTypeException +import android.app.ServiceStartNotAllowedException import android.content.ComponentCallbacks import android.content.Intent import android.content.pm.ActivityInfo +import android.content.pm.PackageManager +import android.content.pm.ServiceInfo import android.content.res.Configuration import android.graphics.Bitmap import android.graphics.BitmapFactory @@ -26,6 +31,7 @@ import android.os.SystemClock import android.widget.Toast import androidx.annotation.AnyThread import androidx.annotation.MainThread +import androidx.core.content.ContextCompat import androidx.core.graphics.createBitmap import androidx.core.graphics.scale import androidx.core.graphics.toColorInt @@ -40,6 +46,16 @@ import info.dvkr.screenstream.common.getLog import info.dvkr.screenstream.common.module.ProjectionCoordinator import info.dvkr.screenstream.mjpeg.MjpegModuleService import info.dvkr.screenstream.mjpeg.R +import info.dvkr.screenstream.mjpeg.internal.audio.EncodedAudioPacket +import info.dvkr.screenstream.mjpeg.internal.audio.MjpegAudioEncoder +import info.dvkr.screenstream.mjpeg.internal.audio.MjpegAudioEncoderUtils +import info.dvkr.screenstream.mjpeg.internal.audio.MjpegAudioPacket +import info.dvkr.screenstream.mjpeg.internal.audio.MjpegAudioSource +import info.dvkr.screenstream.mjpeg.internal.audio.MjpegMasterClock +import info.dvkr.screenstream.mjpeg.internal.mp4.Mp4AudioPacket +import info.dvkr.screenstream.mjpeg.internal.mp4.Mp4Capture +import info.dvkr.screenstream.mjpeg.internal.mp4.Mp4StreamConfig +import info.dvkr.screenstream.mjpeg.internal.mp4.Mp4VideoPacket import info.dvkr.screenstream.mjpeg.settings.MjpegSettings import info.dvkr.screenstream.mjpeg.ui.MjpegError import info.dvkr.screenstream.mjpeg.ui.MjpegState @@ -51,8 +67,11 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.android.asCoroutineDispatcher import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeoutOrNull @@ -79,8 +98,30 @@ internal class MjpegStreamingService( private val supervisorJob = SupervisorJob() private val coroutineScope by lazy(LazyThreadSafetyMode.NONE) { CoroutineScope(supervisorJob + coroutineDispatcher) } private val bitmapStateFlow = MutableStateFlow(createBitmap(1, 1)) + private val audioPacketFlow = MutableSharedFlow( + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + private val mp4ConfigFlow = MutableStateFlow(null) + private val mp4VideoPacketFlow = MutableSharedFlow( + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + private val mp4AudioPacketFlow = MutableSharedFlow( + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) private val httpServer by lazy(mode = LazyThreadSafetyMode.NONE) { - HttpServer(service, mjpegSettings, bitmapStateFlow.asStateFlow(), ::sendEvent) + HttpServer( + service, + mjpegSettings, + bitmapStateFlow.asStateFlow(), + audioPacketFlow.asSharedFlow(), + mp4ConfigFlow.asStateFlow(), + mp4VideoPacketFlow.asSharedFlow(), + mp4AudioPacketFlow.asSharedFlow(), + ::sendEvent + ) } private val projectionCoordinator by lazy(mode = LazyThreadSafetyMode.NONE) { ProjectionCoordinator( @@ -97,8 +138,31 @@ internal class MjpegStreamingService( @MainThread internal fun tryStartProjectionForeground(): Throwable? { - val foregroundStartError = projectionCoordinator.startForegroundForProjection(requiresAudioForegroundService = false) - XLog.i(getLog("tryStartProjectionForeground", "SP_TRACE route=preflight_v1 stage=foreground_preflight audioMode=none result=${foregroundStartError?.javaClass?.simpleName ?: "ok"}")) + val settings = mjpegSettings.data.value + val useMp4 = settings.streamFormat == MjpegSettings.Values.STREAM_FORMAT_MP4 + val audioPermissionGranted = + ContextCompat.checkSelfPermission(service, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED + val deviceAudioSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + val wantsAudio = useMp4 && (settings.enableMic || (deviceAudioSupported && settings.enableDeviceAudio)) + if (!audioPermissionGranted && wantsAudio) { + coroutineScope.launch { + mjpegSettings.updateData { copy(enableMic = false, enableDeviceAudio = false) } + } + } else if (!deviceAudioSupported && settings.enableDeviceAudio) { + coroutineScope.launch { + mjpegSettings.updateData { copy(enableDeviceAudio = false) } + } + } + val wantsAudioForegroundService = audioPermissionGranted && wantsAudio + val foregroundStartError = projectionCoordinator.startForegroundForProjection(wantsAudioForegroundService) + val audioMode = when { + useMp4.not() -> "none" + audioPermissionGranted && settings.enableMic && settings.enableDeviceAudio -> "both" + audioPermissionGranted && settings.enableMic -> "mic" + audioPermissionGranted && settings.enableDeviceAudio -> "device" + else -> "none" + } + XLog.i(getLog("tryStartProjectionForeground", "SP_TRACE route=preflight_v1 stage=foreground_preflight audioMode=$audioMode result=${foregroundStartError?.javaClass?.simpleName ?: "ok"}")) return foregroundStartError } @@ -108,6 +172,18 @@ internal class MjpegStreamingService( service.stopForeground() } + private fun startForegroundForMicrophoneOnly(): Throwable? { + val foregroundServiceType = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE else 0 + return runCatching { service.startForeground(foregroundServiceType) }.exceptionOrNull() + } + + private fun Throwable.toForegroundStartFailGroup(): StartFailGroup = when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && this is ServiceStartNotAllowedException -> StartFailGroup.BLOCKED + Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && this is ForegroundServiceTypeException -> StartFailGroup.FATAL + else -> StartFailGroup.FATAL + } + private val sessionAnalyticsTracker by lazy(LazyThreadSafetyMode.NONE) { StreamingSessionAnalyticsTracker( analytics = streamingAnalytics, @@ -134,6 +210,11 @@ internal class MjpegStreamingService( private var mediaProjectionIntent: Intent? = null private var mediaProjection: MediaProjection? = null private var bitmapCapture: BitmapCapture? = null + private var audioEncoder: MjpegAudioEncoder? = null + private var mp4Capture: Mp4Capture? = null + private var audioGeneration: Long = 0L + private var mp4Generation: Long = 0L + private var componentCallbacksRegistered: Boolean = false private var currentError: MjpegError? = null private var previousError: MjpegError? = null // All vars must be read/write on this (WebRTC-HT) thread @@ -143,6 +224,7 @@ internal class MjpegStreamingService( data class DiscoverAddress(val reason: String, val attempt: Int) : InternalEvent(Priority.RESTART_IGNORE) data class StartServer(val interfaces: List) : InternalEvent(Priority.RESTART_IGNORE) data class StartStream(val permissionEducationShown: Boolean) : InternalEvent(Priority.RESTART_IGNORE) + data class StartMicrophoneAudioOnlyStream(val entryPoint: EntryPoint) : InternalEvent(Priority.RESTART_IGNORE) data object StartStopFromWebPage : InternalEvent(Priority.RESTART_IGNORE) data object ScreenOff : InternalEvent(Priority.RESTART_IGNORE) data class ConfigurationChange(val newConfig: Configuration) : InternalEvent(Priority.RESTART_IGNORE) { @@ -153,6 +235,7 @@ internal class MjpegStreamingService( data class RestartServer(val reason: RestartReason) : InternalEvent(Priority.RESTART_IGNORE) data object UpdateStartBitmap : InternalEvent(Priority.RESTART_IGNORE) + data class AudioCaptureError(val cause: Throwable) : InternalEvent(Priority.RECOVER_IGNORE) data class Error(val error: MjpegError) : InternalEvent(Priority.RECOVER_IGNORE) data class Destroy(val destroyJob: CompletableJob) : InternalEvent(Priority.DESTROY_IGNORE) @@ -163,12 +246,20 @@ internal class MjpegStreamingService( internal sealed class RestartReason(private val msg: String) { object ConnectionChanged : RestartReason("") + object StreamFormatChanged : RestartReason(MjpegSettings.Key.STREAM_FORMAT.name) class SettingsChanged(msg: String) : RestartReason(msg) class NetworkSettingsChanged(msg: String) : RestartReason(msg) override fun toString(): String = "${javaClass.simpleName}[$msg]" } + private data class AudioSettings( + val muteMic: Boolean, + val muteDeviceAudio: Boolean, + val volumeMic: Float, + val volumeDeviceAudio: Float + ) + @Suppress("OVERRIDE_DEPRECATION") private val componentCallback = object : ComponentCallbacks { override fun onConfigurationChanged(newConfig: Configuration) = sendEvent(InternalEvent.ConfigurationChange(newConfig)) @@ -241,6 +332,19 @@ internal class MjpegStreamingService( mjpegSettings.data.map { it.serverPort }.listenForChange(coroutineScope, 1) { sendEvent(InternalEvent.RestartServer(RestartReason.NetworkSettingsChanged(MjpegSettings.Key.SERVER_PORT.name))) } + mjpegSettings.data.map { it.streamFormat }.listenForChange(coroutineScope, 1) { + sendEvent(InternalEvent.RestartServer(RestartReason.StreamFormatChanged)) + } + mjpegSettings.data.map { AudioSettings(it.muteMic, it.muteDeviceAudio, it.volumeMic, it.volumeDeviceAudio) } + .listenForChange(coroutineScope, 1) { settings -> + audioEncoder?.setMute(settings.muteMic, settings.muteDeviceAudio) + audioEncoder?.setVolume(settings.volumeMic, settings.volumeDeviceAudio) + mp4Capture?.setMute(settings.muteMic, settings.muteDeviceAudio) + mp4Capture?.setVolume(settings.volumeMic, settings.volumeDeviceAudio) + } + mjpegSettings.data.map { it.videoBitrateBits }.listenForChange(coroutineScope, 1) { bitrateBits -> + mp4Capture?.setVideoBitrate(bitrateBits) + } } @MainThread @@ -269,6 +373,7 @@ internal class MjpegStreamingService( if (destroyPending) { when (event) { is InternalEvent.StartStream, + is InternalEvent.StartMicrophoneAudioOnlyStream, is MjpegEvent.CastPermissionsDenied, is MjpegEvent.StartProjection -> sessionAnalyticsTracker.onStartAborted() } @@ -331,6 +436,142 @@ internal class MjpegStreamingService( true } + private fun createAndStartAudioEncoder( + settings: MjpegSettings.Data, + enableMic: Boolean, + enableDeviceAudio: Boolean, + mediaProjection: MediaProjection? + ): MjpegAudioEncoder { + val encoderInfo = requireNotNull(MjpegAudioEncoderUtils.selectedOpusEncoder) { "No OPUS audio encoder available" } + val generation = ++audioGeneration + val params = MjpegAudioSource.Params.DEFAULT_OPUS.copy( + bitrate = settings.audioBitrateBits, + echoCanceler = settings.audioEchoCanceller, + noiseSuppressor = settings.audioNoiseSuppressor, + isStereo = true + ) + + val encoder = MjpegAudioEncoder( + codecInfo = encoderInfo, + onAudioPacket = { packet: EncodedAudioPacket -> + audioPacketFlow.tryEmit( + MjpegAudioPacket( + generation = generation, + data = packet.data, + durationSamples = packet.durationSamples + ) + ) + }, + onAudioCaptureError = { + XLog.w(getLog("AudioCapture.onError", it.message), it) + sendEvent(InternalEvent.AudioCaptureError(it)) + }, + onError = { + XLog.w(getLog("AudioEncoder.onError", it.message), it) + sendEvent(InternalEvent.Error(MjpegError.UnknownError(it))) + } + ) + + return encoder.apply { + runCatching { + prepare( + enableMic = enableMic, + enableDeviceAudio = enableDeviceAudio, + dispatcher = Dispatchers.IO, + audioParams = params, + mediaProjection = mediaProjection + ) + setMute(settings.muteMic, settings.muteDeviceAudio) + setVolume(settings.volumeMic, settings.volumeDeviceAudio) + start() + check(isCapturing) { "Audio capture did not start" } + }.onFailure { cause -> + stop() + throw cause + } + } + } + + private fun startMicrophoneAudioOnly(entryPoint: EntryPoint) { + if (pendingStartAttemptId != null) { + XLog.i(getLog("StartMicrophoneAudioOnly", "Permission already pending id=${pendingStartAttemptId ?: "none"}")) + return + } + if (pendingServer || currentError != null || isStreaming) { + XLog.i(getLog("StartMicrophoneAudioOnly", "Not ready. pendingServer=$pendingServer isStreaming=$isStreaming error=${currentError != null}")) + return + } + + val settings = mjpegSettings.data.value + val deviceAudioSelected = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && settings.enableDeviceAudio + val validMode = settings.streamFormat == MjpegSettings.Values.STREAM_FORMAT_MP4 && + settings.streamAudioOnly && settings.enableMic && deviceAudioSelected.not() + if (!validMode) { + XLog.i( + getLog( + "StartMicrophoneAudioOnly", + "Invalid mode. format=${settings.streamFormat} audioOnly=${settings.streamAudioOnly} mic=${settings.enableMic} device=${settings.enableDeviceAudio}" + ) + ) + return + } + + sessionAnalyticsTracker.onStartAttempt( + entryPoint = entryPoint, + usedCachedPermission = false, + permissionEducationShown = false + ) + + val audioPermissionGranted = + ContextCompat.checkSelfPermission(service, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED + if (!audioPermissionGranted) { + coroutineScope.launch { mjpegSettings.updateData { copy(enableMic = false) } } + sessionAnalyticsTracker.onStartFailed(StartFailGroup.PERMISSION_DENIED) + currentError = MjpegError.UnknownError(IllegalStateException("Audio-only stream requires audio recording permission")) + return + } + + startForegroundForMicrophoneOnly()?.let { foregroundError -> + sessionAnalyticsTracker.onStartFailed(foregroundError.toForegroundStartFailGroup()) + currentError = MjpegError.UnknownError(foregroundError) + return + } + + runCatching { + MjpegMasterClock.reset() + MjpegMasterClock.ensureStarted() + val capture = Mp4Capture( + serviceContext = service, + settings = settings, + mediaProjection = null, + generation = ++mp4Generation, + enableMic = true, + enableDeviceAudio = false, + dispatcher = Dispatchers.IO, + configFlow = mp4ConfigFlow, + videoPacketFlow = mp4VideoPacketFlow, + audioPacketFlow = mp4AudioPacketFlow, + onError = { error -> sendEvent(InternalEvent.Error(error)) }, + onAudioCaptureError = { cause -> sendEvent(InternalEvent.AudioCaptureError(cause)) } + ) + check(capture.start { true }) { "MP4 audio capture did not start" } + mp4Capture = capture + isStreaming = true + currentError = null + sessionAnalyticsTracker.onStarted(currentActiveConsumersCount()) + XLog.i(getLog("StartMicrophoneAudioOnly", "Started. entryPoint=$entryPoint")) + }.onFailure { cause -> + XLog.e(getLog("StartMicrophoneAudioOnly", "Failed: ${cause.message}"), cause) + mp4Capture?.stop() + mp4Capture = null + mp4ConfigFlow.value = null + isStreaming = false + service.stopForeground() + sessionAnalyticsTracker.onStartFailed(StartFailGroup.FATAL) + currentError = MjpegError.UnknownError(cause) + } + } + // On MJPEG-HT only private suspend fun processEvent(event: MjpegEvent) { when (event) { @@ -346,6 +587,10 @@ internal class MjpegStreamingService( if (event.clearIntent) mediaProjectionIntent = null mediaProjection = null bitmapCapture = null + audioEncoder = null + mp4Capture = null + mp4ConfigFlow.value = null + componentCallbacksRegistered = false currentError = null } @@ -386,6 +631,12 @@ internal class MjpegStreamingService( is InternalEvent.StartStopFromWebPage -> when { isStreaming -> sendEvent(MjpegEvent.Intentable.StopStream("StartStopFromWebPage")) + mjpegSettings.data.value.streamFormat == MjpegSettings.Values.STREAM_FORMAT_MP4 && + mjpegSettings.data.value.streamAudioOnly && + mjpegSettings.data.value.enableMic && + (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q || mjpegSettings.data.value.enableDeviceAudio.not()) -> + startMicrophoneAudioOnly(EntryPoint.WEB) + pendingServer.not() && currentError == null -> { if (pendingStartAttemptId != null) { XLog.i(getLog("StartStopFromWebPage", "Permission already pending id=${pendingStartAttemptId ?: "none"}")) @@ -437,6 +688,8 @@ internal class MjpegStreamingService( } } + is InternalEvent.StartMicrophoneAudioOnlyStream -> startMicrophoneAudioOnly(event.entryPoint) + is MjpegEvent.CastPermissionsDenied -> { val currentStartAttemptId = pendingStartAttemptId if (currentStartAttemptId != event.startAttemptId) { @@ -480,25 +733,105 @@ internal class MjpegStreamingService( return } + val settings = mjpegSettings.data.value + val useMp4 = settings.streamFormat == MjpegSettings.Values.STREAM_FORMAT_MP4 + val streamAudioOnly = useMp4 && settings.streamAudioOnly + val deviceAudioSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + val audioRequested = useMp4 && (settings.enableMic || (deviceAudioSupported && settings.enableDeviceAudio)) + if (streamAudioOnly && !audioRequested) { + clearPreparedProjectionStartIfNeeded(event.foregroundStartProcessed, event.foregroundStartError) + pendingStartAttemptId = null + throw IllegalStateException("Audio-only stream requires microphone or device audio") + } + pendingStartAttemptId = null val startProjection = { - projectionCoordinator.startProjection(event.intent) { _, mediaProjection, _, isStartupStillValid -> + projectionCoordinator.startProjection(event.intent) { _, mediaProjection, audioCaptureAllowed, isStartupStillValid -> mediaProjection.registerCallback(projectionCallback, mainHandler) - val bitmapCapture = BitmapCapture(service, mjpegSettings, mediaProjection, bitmapStateFlow) { error -> - sendEvent(InternalEvent.Error(error)) - } - val captureStarted = bitmapCapture.start(isStartupStillValid) - if (!captureStarted) { - XLog.i(getLog("StartProjection", "Capture not started. Stopping projection.")) - bitmapCapture.destroy() + MjpegMasterClock.reset() + MjpegMasterClock.ensureStarted() + + var bitmapCapture: BitmapCapture? = null + var audioEncoder: MjpegAudioEncoder? = null + var mp4Capture: Mp4Capture? = null + + val audioPermissionGranted = + ContextCompat.checkSelfPermission(service, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED + val enableMic = useMp4 && audioPermissionGranted && settings.enableMic && audioCaptureAllowed + val enableDeviceAudio = useMp4 && audioPermissionGranted && deviceAudioSupported && settings.enableDeviceAudio + val enableAudio = enableMic || enableDeviceAudio + if (streamAudioOnly && !enableAudio) { mediaProjection.unregisterCallback(projectionCallback) - return@startProjection false + throw IllegalStateException("Audio-only stream requires audio recording permission") + } + + if (useMp4) { + mp4Capture = Mp4Capture( + serviceContext = service, + settings = settings, + mediaProjection = mediaProjection, + generation = ++mp4Generation, + enableMic = enableMic, + enableDeviceAudio = enableDeviceAudio, + dispatcher = Dispatchers.IO, + configFlow = mp4ConfigFlow, + videoPacketFlow = mp4VideoPacketFlow, + audioPacketFlow = mp4AudioPacketFlow, + onError = { error -> sendEvent(InternalEvent.Error(error)) }, + onAudioCaptureError = { cause -> sendEvent(InternalEvent.AudioCaptureError(cause)) } + ) + val captureStarted = mp4Capture.start(isStartupStillValid) + if (!captureStarted) { + XLog.i(getLog("StartProjection", "MP4 capture not started. Stopping projection.")) + mp4Capture.stop() + mediaProjection.unregisterCallback(projectionCallback) + return@startProjection false + } + if (!isStartupStillValid()) { + XLog.i(getLog("StartProjection", "Startup invalidated after MP4 capture start.")) + mp4Capture.stop() + mediaProjection.unregisterCallback(projectionCallback) + return@startProjection false + } + } else if (!streamAudioOnly) { + bitmapCapture = BitmapCapture(service, mjpegSettings, mediaProjection, bitmapStateFlow) { error -> + sendEvent(InternalEvent.Error(error)) + } + val captureStarted = bitmapCapture.start(isStartupStillValid) + if (!captureStarted) { + XLog.i(getLog("StartProjection", "Capture not started. Stopping projection.")) + bitmapCapture.destroy() + mediaProjection.unregisterCallback(projectionCallback) + return@startProjection false + } + if (!isStartupStillValid()) { + XLog.i(getLog("StartProjection", "Startup invalidated after capture start.")) + bitmapCapture.destroy() + mediaProjection.unregisterCallback(projectionCallback) + return@startProjection false + } + } + + if (!useMp4 && enableAudio) { + audioEncoder = runCatching { + createAndStartAudioEncoder(settings, enableMic, enableDeviceAudio, mediaProjection) + }.getOrElse { cause -> + if (streamAudioOnly) { + bitmapCapture?.destroy() + mediaProjection.unregisterCallback(projectionCallback) + throw cause + } + XLog.w(getLog("StartProjection", "Audio capture unavailable. Continuing video-only: ${cause.message}"), cause) + null + } } if (!isStartupStillValid()) { - XLog.i(getLog("StartProjection", "Startup invalidated after capture start.")) - bitmapCapture.destroy() + XLog.i(getLog("StartProjection", "Startup invalidated after audio start.")) + bitmapCapture?.destroy() + audioEncoder?.stop() + mp4Capture?.stop() mediaProjection.unregisterCallback(projectionCallback) return@startProjection false } @@ -506,11 +839,12 @@ internal class MjpegStreamingService( if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { mediaProjectionIntent = event.intent service.registerComponentCallbacks(componentCallback) + componentCallbacksRegistered = true } @Suppress("DEPRECATION") @SuppressLint("WakelockTimeout") - if (Build.MANUFACTURER !in listOf("OnePlus", "OPPO") && mjpegSettings.data.value.keepAwake) { + if (!streamAudioOnly && Build.MANUFACTURER !in listOf("OnePlus", "OPPO") && settings.keepAwake) { val flags = PowerManager.SCREEN_DIM_WAKE_LOCK or PowerManager.ACQUIRE_CAUSES_WAKEUP wakeLock = powerManager.newWakeLock(flags, "ScreenStream::MJPEG-Tag").apply { acquire() } } @@ -518,6 +852,8 @@ internal class MjpegStreamingService( this@MjpegStreamingService.isStreaming = true this@MjpegStreamingService.mediaProjection = mediaProjection this@MjpegStreamingService.bitmapCapture = bitmapCapture + this@MjpegStreamingService.audioEncoder = audioEncoder + this@MjpegStreamingService.mp4Capture = mp4Capture true } } @@ -532,7 +868,9 @@ internal class MjpegStreamingService( startProjection() } } else { - val foregroundError = projectionCoordinator.startForegroundForProjection(requiresAudioForegroundService = false) + val audioPermissionGranted = + ContextCompat.checkSelfPermission(service, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED + val foregroundError = projectionCoordinator.startForegroundForProjection(audioPermissionGranted && audioRequested) if (foregroundError != null) { startPhase = "foreground promotion" projectionCoordinator.asForegroundStartResult(foregroundError) @@ -638,7 +976,11 @@ internal class MjpegStreamingService( configDiff and ActivityInfo.CONFIG_ORIENTATION != 0 || configDiff and ActivityInfo.CONFIG_SCREEN_LAYOUT != 0 || configDiff and ActivityInfo.CONFIG_SCREEN_SIZE != 0 || configDiff and ActivityInfo.CONFIG_DENSITY != 0 ) { - bitmapCapture?.resize() + if (mp4Capture != null) { + XLog.i(getLog("ConfigurationChange", "Active MP4 stream keeps existing virtual display. Ignoring resize.")) + } else { + bitmapCapture?.resize() + } } else { XLog.d(getLog("ConfigurationChange", "No change relevant for streaming. Ignoring.")) } @@ -657,14 +999,20 @@ internal class MjpegStreamingService( return } if (isStreaming) { - bitmapCapture?.resize(event.width, event.height) + if (mp4Capture != null) { + XLog.i(getLog("CapturedContentResize", "Active MP4 stream keeps existing virtual display: ${event.width} x ${event.height}")) + } else { + bitmapCapture?.resize(event.width, event.height) + } } else { XLog.d(getLog("CapturedContentResize", "Not streaming. Ignoring.")) } } is InternalEvent.RestartServer -> { - if (mjpegSettings.data.value.stopOnConfigurationChange) stopStream("ConfigurationChange") + if (mjpegSettings.data.value.stopOnConfigurationChange || event.reason is RestartReason.StreamFormatChanged) { + stopStream("ConfigurationChange") + } pendingStartAttemptId = null waitingForPermission = false @@ -691,6 +1039,22 @@ internal class MjpegStreamingService( if (isStreaming.not() && mjpegSettings.data.value.htmlShowPressStart) bitmapStateFlow.value = getStartBitmap() } + is InternalEvent.AudioCaptureError -> { + if (mp4Capture != null) { + stopStream("Mp4AudioCaptureError") + currentError = MjpegError.UnknownError(event.cause) + return + } + audioEncoder?.stop() + audioEncoder = null + if (bitmapCapture == null) { + stopStream("AudioCaptureError") + currentError = MjpegError.UnknownError(event.cause) + } else { + XLog.w(getLog("AudioCaptureError", "Continuing video-only: ${event.cause.message}"), event.cause) + } + } + is MjpegEvent.Intentable.RecoverError -> { stopStream("RecoverError") httpServer.stop(true) @@ -754,11 +1118,17 @@ internal class MjpegStreamingService( } if (wasStreaming) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - service.unregisterComponentCallbacks(componentCallback) + if (componentCallbacksRegistered && Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + runCatching { service.unregisterComponentCallbacks(componentCallback) } + componentCallbacksRegistered = false } bitmapCapture?.destroy() bitmapCapture = null + audioEncoder?.stop() + audioEncoder = null + mp4Capture?.stop() + mp4Capture = null + mp4ConfigFlow.value = null mediaProjection?.unregisterCallback(projectionCallback) mediaProjection = null projectionCoordinator.stop() diff --git a/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/audio/MjpegAudioCapture.kt b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/audio/MjpegAudioCapture.kt new file mode 100644 index 00000000..3da00d7b --- /dev/null +++ b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/audio/MjpegAudioCapture.kt @@ -0,0 +1,362 @@ +package info.dvkr.screenstream.mjpeg.internal.audio + +import android.Manifest +import android.media.AudioFormat +import android.media.AudioPlaybackCaptureConfiguration +import android.media.AudioRecord +import android.media.MediaRecorder +import android.media.audiofx.AcousticEchoCanceler +import android.media.audiofx.AutomaticGainControl +import android.media.audiofx.NoiseSuppressor +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.annotation.RequiresPermission +import com.elvishew.xlog.XLog +import info.dvkr.screenstream.common.getLog +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.nio.ByteBuffer +import java.nio.ByteOrder + +internal class MjpegAudioCapture( + private val audioParams: MjpegAudioSource.Params, + private val dispatcher: CoroutineDispatcher, + private val onAudioFrame: (MjpegAudioSource.Frame) -> Unit, + private val onCaptureError: (Throwable) -> Unit, +) { + private var audioRecord: AudioRecord? = null + private var scope: CoroutineScope? = null + + private var acousticEchoCanceler: AcousticEchoCanceler? = null + private var noiseSuppressor: NoiseSuppressor? = null + private var automaticGainControl: AutomaticGainControl? = null + + private var running: Boolean = false + private var muted: Boolean = false + @Volatile private var stopping: Boolean = false + + @get:Synchronized + @set:Synchronized + var volume: Float = 1.0f + set(value) { + field = value.coerceIn(0f, 2f) + } + + @Synchronized + fun isRunning(): Boolean = running + + @Synchronized + fun isMuted(): Boolean = muted + + @Synchronized + fun setMuted(value: Boolean) { + muted = value + } + + @RequiresPermission(Manifest.permission.RECORD_AUDIO) + fun checkIfConfigurationSupported(audioSource: Int = MediaRecorder.AudioSource.DEFAULT) { + val channelConfig = if (audioParams.isStereo) AudioFormat.CHANNEL_IN_STEREO else AudioFormat.CHANNEL_IN_MONO + val minBufferSize = AudioRecord.getMinBufferSize(audioParams.sampleRate, channelConfig, AudioFormat.ENCODING_PCM_16BIT) + + if (minBufferSize == AudioRecord.ERROR || minBufferSize == AudioRecord.ERROR_BAD_VALUE) { + throw IllegalArgumentException("Invalid audio parameters.") + } + + var testRecord: AudioRecord? = null + try { + testRecord = AudioRecord(audioSource, audioParams.sampleRate, channelConfig, AudioFormat.ENCODING_PCM_16BIT, minBufferSize) + if (testRecord.state != AudioRecord.STATE_INITIALIZED) { + throw IllegalArgumentException("Failed to initialize AudioRecord.") + } + } finally { + testRecord?.release() + } + } + + @RequiresApi(Build.VERSION_CODES.Q) + @RequiresPermission(Manifest.permission.RECORD_AUDIO) + fun checkIfConfigurationSupported(config: AudioPlaybackCaptureConfiguration) { + val bufferSizeInBytes = audioParams.calculateBufferSizeInBytes() + var testRecord: AudioRecord? = null + try { + testRecord = AudioRecord.Builder() + .setAudioPlaybackCaptureConfig(config) + .setAudioFormat( + AudioFormat.Builder() + .setEncoding(AudioFormat.ENCODING_PCM_16BIT) + .setSampleRate(audioParams.sampleRate) + .setChannelMask(if (audioParams.isStereo) AudioFormat.CHANNEL_IN_STEREO else AudioFormat.CHANNEL_IN_MONO) + .build() + ) + .setBufferSizeInBytes(bufferSizeInBytes) + .build() + if (testRecord.state != AudioRecord.STATE_INITIALIZED) { + throw IllegalArgumentException("Failed to initialize playback AudioRecord.") + } + } finally { + testRecord?.release() + } + } + + @Synchronized + @RequiresPermission(Manifest.permission.RECORD_AUDIO) + fun start(audioSource: Int) { + var record: AudioRecord? = null + try { + check(!running) { "Audio capture is already running." } + stopping = false + + val (preparedRecord, bufferSizeInBytes) = createAndStart(audioSource) + record = preparedRecord + audioRecord = preparedRecord + running = true + + scope = CoroutineScope(Job() + dispatcher).apply { + launch { readAudioLoop(record, bufferSizeInBytes) { createAndStart(audioSource) } } + } + } catch (cause: Exception) { + cleanupFailedStart(record) + XLog.w(getLog("start", "Failed to start audio capture: ${cause.message}"), cause) + onCaptureError(cause) + } + } + + @Synchronized + @RequiresApi(Build.VERSION_CODES.Q) + @RequiresPermission(Manifest.permission.RECORD_AUDIO) + fun start(config: AudioPlaybackCaptureConfiguration) { + var record: AudioRecord? = null + try { + check(!running) { "Audio capture is already running." } + stopping = false + + val (preparedRecord, bufferSizeInBytes) = createAndStart(config) + record = preparedRecord + audioRecord = preparedRecord + running = true + + scope = CoroutineScope(Job() + dispatcher).apply { + launch { readAudioLoop(record, bufferSizeInBytes) { createAndStart(config) } } + } + } catch (cause: Exception) { + cleanupFailedStart(record) + XLog.w(getLog("start", "Failed to start playback audio capture: ${cause.message}"), cause) + onCaptureError(cause) + } + } + + @Synchronized + fun stop() { + if (!running) return + stopping = true + running = false + + scope?.cancel() + scope = null + + runCatching { audioRecord?.stop() } + audioRecord?.release() + audioRecord = null + + releaseAudioEffects() + } + + private fun cleanupFailedStart(record: AudioRecord?) { + scope?.cancel() + scope = null + running = false + + runCatching { record?.stop() } + record?.release() + audioRecord = null + releaseAudioEffects() + } + + private suspend fun readAudioLoop( + record: AudioRecord, + bufferSizeInBytes: Int, + recreateRecord: () -> Pair + ) = runCatching { + var currentRecord = record + var currentBufferSize = bufferSizeInBytes + var pcmByteBuffer = ByteBuffer.allocateDirect(currentBufferSize).order(ByteOrder.nativeOrder()) + var shortBuffer = pcmByteBuffer.asShortBuffer() + var retryAttempted = false + + while (currentCoroutineContext().isActive) { + if (!isRunning()) break + pcmByteBuffer.clear() + val size = currentRecord.read(pcmByteBuffer, currentBufferSize, AudioRecord.READ_BLOCKING) + when { + size > 0 -> { + if (size % 2 != 0) continue + + if (isMuted()) { + onAudioFrame(MjpegAudioSource.Frame(ByteArray(size), size, MjpegMasterClock.relativeTimeUs())) + continue + } + + if (volume != 1.0f) { + val sampleCount = size / 2 + shortBuffer.clear() + shortBuffer.limit(sampleCount) + for (i in 0 until sampleCount) { + val sample = shortBuffer.get(i) + val scaled = (sample * volume).toInt() + shortBuffer.put( + i, + when { + scaled > Short.MAX_VALUE -> Short.MAX_VALUE + scaled < Short.MIN_VALUE -> Short.MIN_VALUE + else -> scaled.toShort() + } + ) + } + } + + val frameBuffer = ByteArray(size) + pcmByteBuffer.position(0) + pcmByteBuffer.get(frameBuffer, 0, size) + onAudioFrame(MjpegAudioSource.Frame(frameBuffer, size, MjpegMasterClock.relativeTimeUs())) + } + + size < 0 -> { + if (!isRunning() || stopping || currentRecord.recordingState != AudioRecord.RECORDSTATE_RECORDING) break + if (size == AudioRecord.ERROR_BAD_VALUE && !retryAttempted) { + retryAttempted = true + runCatching { currentRecord.stop() } + currentRecord.release() + releaseAudioEffects() + + val (newRecord, newBufferSize) = recreateRecord() + currentRecord = newRecord + currentBufferSize = newBufferSize + pcmByteBuffer = ByteBuffer.allocateDirect(currentBufferSize).order(ByteOrder.nativeOrder()) + shortBuffer = pcmByteBuffer.asShortBuffer() + audioRecord = newRecord + continue + } + onCaptureError(IllegalStateException("AudioRecord read failed (code=$size).")) + break + } + } + } + }.onFailure { cause -> + if (cause is CancellationException) throw cause + XLog.w(getLog("readAudioLoop", "Failed to read audio: ${cause.message}"), cause) + onCaptureError(cause) + } + + @RequiresPermission(Manifest.permission.RECORD_AUDIO) + private fun createAndStart(audioSource: Int): Pair { + val bufferSizeInBytes = audioParams.calculateBufferSizeInBytes() + val record = AudioRecord( + audioSource, + audioParams.sampleRate, + if (audioParams.isStereo) AudioFormat.CHANNEL_IN_STEREO else AudioFormat.CHANNEL_IN_MONO, + AudioFormat.ENCODING_PCM_16BIT, + bufferSizeInBytes + ) + if (record.state != AudioRecord.STATE_INITIALIZED) { + record.release() + throw IllegalArgumentException("Failed to initialize AudioRecord.") + } + initializeAudioEffects(record.audioSessionId, audioParams) + record.startRecording() + if (record.recordingState != AudioRecord.RECORDSTATE_RECORDING) { + record.release() + throw IllegalArgumentException("Failed to start AudioRecord.") + } + return record to bufferSizeInBytes + } + + @RequiresApi(Build.VERSION_CODES.Q) + @RequiresPermission(Manifest.permission.RECORD_AUDIO) + private fun createAndStart(config: AudioPlaybackCaptureConfiguration): Pair { + val bufferSizeInBytes = audioParams.calculateBufferSizeInBytes() + val record = AudioRecord.Builder() + .setAudioPlaybackCaptureConfig(config) + .setAudioFormat( + AudioFormat.Builder() + .setEncoding(AudioFormat.ENCODING_PCM_16BIT) + .setSampleRate(audioParams.sampleRate) + .setChannelMask(if (audioParams.isStereo) AudioFormat.CHANNEL_IN_STEREO else AudioFormat.CHANNEL_IN_MONO) + .build() + ) + .setBufferSizeInBytes(bufferSizeInBytes) + .build() + + if (record.state != AudioRecord.STATE_INITIALIZED) { + record.release() + throw IllegalArgumentException("Failed to initialize playback AudioRecord.") + } + initializeAudioEffects(record.audioSessionId, audioParams) + record.startRecording() + if (record.recordingState != AudioRecord.RECORDSTATE_RECORDING) { + record.release() + throw IllegalArgumentException("Failed to start playback AudioRecord.") + } + return record to bufferSizeInBytes + } + + @Throws(Exception::class) + private fun initializeAudioEffects(audioSessionId: Int, audioParams: MjpegAudioSource.Params) { + enableEchoCanceler(audioSessionId, audioParams.echoCanceler) + enableNoiseSuppressor(audioSessionId, audioParams.noiseSuppressor) + enableAutoGainControl(audioSessionId, audioParams.autoGainControl) + } + + private fun enableAutoGainControl(audioSession: Int, enable: Boolean) { + releaseAutoGainControl() + if (enable && AutomaticGainControl.isAvailable()) { + automaticGainControl = AutomaticGainControl.create(audioSession) + automaticGainControl?.enabled = true + } + } + + private fun enableEchoCanceler(audioSession: Int, enable: Boolean) { + releaseEchoCanceler() + if (enable && AcousticEchoCanceler.isAvailable()) { + acousticEchoCanceler = AcousticEchoCanceler.create(audioSession) + acousticEchoCanceler?.enabled = true + } + } + + private fun enableNoiseSuppressor(audioSession: Int, enable: Boolean) { + releaseNoiseSuppressor() + if (enable && NoiseSuppressor.isAvailable()) { + noiseSuppressor = NoiseSuppressor.create(audioSession) + noiseSuppressor?.enabled = true + } + } + + private fun releaseAutoGainControl() { + automaticGainControl?.enabled = false + automaticGainControl?.release() + automaticGainControl = null + } + + private fun releaseEchoCanceler() { + acousticEchoCanceler?.enabled = false + acousticEchoCanceler?.release() + acousticEchoCanceler = null + } + + private fun releaseNoiseSuppressor() { + noiseSuppressor?.enabled = false + noiseSuppressor?.release() + noiseSuppressor = null + } + + private fun releaseAudioEffects() { + releaseAutoGainControl() + releaseEchoCanceler() + releaseNoiseSuppressor() + } +} diff --git a/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/audio/MjpegAudioEncoder.kt b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/audio/MjpegAudioEncoder.kt new file mode 100644 index 00000000..14fa195c --- /dev/null +++ b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/audio/MjpegAudioEncoder.kt @@ -0,0 +1,295 @@ +package info.dvkr.screenstream.mjpeg.internal.audio + +import android.media.MediaCodec +import android.media.MediaCodecInfo +import android.media.MediaFormat +import android.media.MediaRecorder +import android.media.projection.MediaProjection +import android.os.Handler +import android.os.HandlerThread +import android.os.Process +import com.elvishew.xlog.XLog +import info.dvkr.screenstream.common.getLog +import kotlinx.coroutines.CoroutineDispatcher +import java.util.concurrent.ArrayBlockingQueue + +internal class MjpegAudioEncoder( + private val codecInfo: MjpegAudioEncoderInfo, + private val onAudioPacket: (EncodedAudioPacket) -> Unit, + private val onAudioCaptureError: (Throwable) -> Unit, + private val onError: (Throwable) -> Unit, +) { + private enum class State { IDLE, PREPARED, RUNNING, STOPPED } + + private data class StopSnapshot( + val audioSource: MjpegAudioSource?, + val audioEncoder: MediaCodec?, + val handler: Handler?, + val handlerThread: HandlerThread? + ) + + private val encoderLock = Any() + private var currentState = State.IDLE + set(value) { + field = value + XLog.v(getLog("currentState", "State changed to: $value")) + } + + private var audioSource: MjpegAudioSource? = null + private var audioEncoder: MediaCodec? = null + private var handlerThread: HandlerThread? = null + private var handler: Handler? = null + + private val frameQueue = ArrayBlockingQueue(16) + private val durationSamplesQueue = ArrayBlockingQueue(16) + private var channelCount: Int = 2 + + internal val isCapturing: Boolean + get() = synchronized(encoderLock) { audioSource?.isRunning == true } + + internal fun prepare( + enableMic: Boolean, + enableDeviceAudio: Boolean, + dispatcher: CoroutineDispatcher, + audioParams: MjpegAudioSource.Params, + mediaProjection: MediaProjection?, + ) { + var audioSourceConfigurationError: Throwable? = null + runCatching { + synchronized(encoderLock) { + check(currentState == State.IDLE) + channelCount = if (audioParams.isStereo) 2 else 1 + + val onAudioSourceFrame: (MjpegAudioSource.Frame) -> Unit = { audioFrame -> + synchronized(encoderLock) { + if (currentState != State.RUNNING) return@synchronized + if (!frameQueue.offer(audioFrame)) { + XLog.w(getLog("start", "Audio frame queue is full. Dropping frame.")) + } + } + } + + audioSource = when { + enableMic && enableDeviceAudio -> { + val projection = requireNotNull(mediaProjection) { "MediaProjection is required for internal audio capture" } + MjpegMixAudioSource(audioParams, MediaRecorder.AudioSource.DEFAULT, projection, dispatcher, onAudioSourceFrame, onAudioCaptureError) + } + + enableMic -> + MjpegMicrophoneSource(audioParams, MediaRecorder.AudioSource.DEFAULT, dispatcher, onAudioSourceFrame, onAudioCaptureError) + + enableDeviceAudio -> { + val projection = requireNotNull(mediaProjection) { "MediaProjection is required for internal audio capture" } + MjpegInternalAudioSource(audioParams, projection, dispatcher, onAudioSourceFrame, onAudioCaptureError) + } + + else -> null + } + + audioSource?.let { + audioSourceConfigurationError = runCatching { it.checkIfConfigurationSupported() }.exceptionOrNull() + if (audioSourceConfigurationError != null) return@synchronized + } + + if (audioSource == null) return + + val format = MediaFormat.createAudioFormat(OPUS_MIME_TYPE, audioParams.sampleRate, channelCount).apply { + setInteger(MediaFormat.KEY_BIT_RATE, audioParams.bitrate) + setInteger(MediaFormat.KEY_OPERATING_RATE, audioParams.sampleRate) + setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, audioParams.calculateBufferSizeInBytes() + 64) + setInteger(MediaFormat.KEY_PRIORITY, 1) + if (codecInfo.isCBRModeSupported) { + setInteger(MediaFormat.KEY_BITRATE_MODE, MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_CBR) + } else { + setInteger(MediaFormat.KEY_BITRATE_MODE, MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_VBR) + } + } + + val encoder = MediaCodec.createByCodecName(codecInfo.name) + encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) + + handlerThread = HandlerThread("MjpegAudioEncoderHandler", Process.THREAD_PRIORITY_AUDIO).apply { start() } + handler = Handler(handlerThread!!.looper) + encoder.setCallback(createCodecCallback(), handler) + + audioEncoder = encoder + currentState = State.PREPARED + } + }.onFailure { cause -> + stopInternal(force = true, logTag = "cleanupAfterPrepareFailure") + onError(cause) + } + + val configurationError = audioSourceConfigurationError ?: return + stopInternal(force = true, logTag = "cleanupAfterPrepareFailure") + onAudioCaptureError(configurationError) + } + + internal fun start(): Unit = synchronized(encoderLock) { + if (audioSource == null) { + XLog.i(getLog("start", "No audioSource. Ignoring")) + return + } + if (currentState != State.PREPARED) { + XLog.w(getLog("start", "Cannot start unless state is PREPARED. Current: $currentState")) + return + } + + audioEncoder?.start() + audioSource!!.start() + currentState = State.RUNNING + } + + internal fun stop() { + if (stopInternal(force = false, logTag = "stopInternal").not()) return + } + + internal fun setMute(micMute: Boolean, deviceMute: Boolean) { + when (val source = audioSource) { + is MjpegMicrophoneSource -> source.setMute(micMute) + is MjpegInternalAudioSource -> source.setMute(deviceMute) + is MjpegMixAudioSource -> source.setMute(micMute, deviceMute) + } + } + + internal fun setVolume(micVolume: Float, deviceVolume: Float) { + when (val source = audioSource) { + is MjpegMicrophoneSource -> source.volume = micVolume + is MjpegInternalAudioSource -> source.volume = deviceVolume + is MjpegMixAudioSource -> source.setVolume(micVolume, deviceVolume) + } + } + + private fun stopInternal(force: Boolean, logTag: String): Boolean { + val snapshot = synchronized(encoderLock) { + if (audioSource == null && !force) return@synchronized null + if (!force && (currentState == State.IDLE || currentState == State.STOPPED)) return@synchronized null + + currentState = State.STOPPED + + StopSnapshot(audioSource, audioEncoder, handler, handlerThread).also { + audioSource = null + audioEncoder = null + handler = null + handlerThread = null + } + } ?: return false + + runCatching { snapshot.audioSource?.stop() } + + snapshot.audioEncoder?.runCatching { + stop() + release() + }?.onFailure { + XLog.w(getLog(logTag, "mediaCodec.stop() exception: ${it.message}"), it) + } + + snapshot.handler?.removeCallbacksAndMessages(null) + snapshot.handlerThread?.apply { + quitSafely() + runCatching { join(250) }.onFailure { + XLog.w(getLog(logTag, "handlerThread.join() interrupted"), it) + } + if (isAlive) { + quit() + runCatching { join(250) } + } + } + + frameQueue.clear() + durationSamplesQueue.clear() + + synchronized(encoderLock) { + currentState = State.IDLE + } + return true + } + + private fun isCallbackCodecActive(codec: MediaCodec): Boolean = synchronized(encoderLock) { + codec === audioEncoder && currentState == State.RUNNING + } + + private fun createCodecCallback(): MediaCodec.Callback = object : MediaCodec.Callback() { + override fun onInputBufferAvailable(codec: MediaCodec, index: Int) { + runCatching { + synchronized(encoderLock) { + if (codec !== audioEncoder) return + if (currentState != State.RUNNING) { + runCatching { codec.queueInputBuffer(index, 0, 0, 0, 0) } + return + } + + val frame = frameQueue.poll() ?: run { + runCatching { codec.queueInputBuffer(index, 0, 0, 0, 0) } + return + } + + val inputBuffer = codec.getInputBuffer(index) ?: run { + runCatching { codec.queueInputBuffer(index, 0, 0, 0, 0) } + return + } + + inputBuffer.clear() + if (inputBuffer.remaining() < frame.size) { + runCatching { codec.queueInputBuffer(index, 0, 0, 0, 0) } + throw IllegalArgumentException("Frame too large for input buffer") + } + + inputBuffer.put(frame.buffer, 0, frame.size) + val samplesPerChannel = (frame.size / 2 / channelCount).coerceAtLeast(1) + durationSamplesQueue.offer(samplesPerChannel) + codec.queueInputBuffer(index, 0, frame.size, frame.timestampUs, 0) + } + }.onFailure { cause -> + if (!isCallbackCodecActive(codec)) return@onFailure + XLog.w(getLog("CodecCallback.onInputBufferAvailable", "onFailure: ${cause.message}"), cause) + onError(cause) + } + } + + override fun onOutputBufferAvailable(codec: MediaCodec, index: Int, info: MediaCodec.BufferInfo) { + runCatching { + val packet = synchronized(encoderLock) { + if (codec !== audioEncoder || currentState != State.RUNNING || info.size == 0 || + info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0 + ) { + runCatching { codec.releaseOutputBuffer(index, false) } + return + } + + val outputBuffer = codec.getOutputBuffer(index) ?: run { + runCatching { codec.releaseOutputBuffer(index, false) } + return + } + + outputBuffer.position(info.offset) + outputBuffer.limit(info.offset + info.size) + val data = ByteArray(info.size) + outputBuffer.get(data) + val durationSamples = durationSamplesQueue.poll() ?: 960 + + codec.releaseOutputBuffer(index, false) + EncodedAudioPacket(data, durationSamples) + } + + onAudioPacket(packet) + }.onFailure { cause -> + runCatching { codec.releaseOutputBuffer(index, false) } + if (!isCallbackCodecActive(codec)) return@onFailure + XLog.w(getLog("CodecCallback.onOutputBufferAvailable", "onFailure: ${cause.message}"), cause) + onError(cause) + } + } + + override fun onError(codec: MediaCodec, cause: MediaCodec.CodecException) { + if (!isCallbackCodecActive(codec)) return + XLog.w(getLog("CodecCallback.onError", "onFailure: ${cause.message}"), cause) + onError(cause) + } + + override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat) { + if (!isCallbackCodecActive(codec)) return + XLog.i(getLog("CodecCallback.onOutputFormatChanged", "codec: $codec, format: $format")) + } + } +} diff --git a/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/audio/MjpegAudioEncoderUtils.kt b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/audio/MjpegAudioEncoderUtils.kt new file mode 100644 index 00000000..d457cd66 --- /dev/null +++ b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/audio/MjpegAudioEncoderUtils.kt @@ -0,0 +1,91 @@ +package info.dvkr.screenstream.mjpeg.internal.audio + +import android.media.MediaCodecInfo +import android.media.MediaCodecInfo.EncoderCapabilities +import android.media.MediaCodecList +import android.os.Build +import android.util.Range +import androidx.core.util.toClosedRange +import kotlin.math.ceil +import kotlin.math.floor + +internal object MjpegAudioEncoderUtils { + + private val allAvailableEncoders: List by lazy { + MediaCodecList(MediaCodecList.ALL_CODECS).codecInfos.filter { it.isEncoder } + } + + val availableOpusEncoders: List by lazy { + allAvailableEncoders + .filter { encoder -> encoder.supportedTypes.any { it.equals(OPUS_MIME_TYPE, ignoreCase = true) } } + .sortedByDescending { getCodecScore(it.name) } + .map { encoder -> + MjpegAudioEncoderInfo( + name = encoder.name, + vendorName = encoder.name.asVendorName(), + isHardwareAccelerated = encoder.isHardwareAcceleratedCompat(), + isCBRModeSupported = encoder.isCbrCapable(OPUS_MIME_TYPE), + capabilities = encoder.getCapabilitiesForType(OPUS_MIME_TYPE) + ) + } + .sortedWith(compareBy({ if (it.isHardwareAccelerated) 0 else 1 }, { if (it.isCBRModeSupported) 0 else 1 })) + } + + val selectedOpusEncoder: MjpegAudioEncoderInfo? + get() = availableOpusEncoders.firstOrNull() + + private fun MediaCodecInfo.isCbrCapable(mimeType: String): Boolean = + runCatching { + getCapabilitiesForType(mimeType) + ?.encoderCapabilities + ?.isBitrateModeSupported(EncoderCapabilities.BITRATE_MODE_CBR) ?: false + }.getOrDefault(false) + + private fun MediaCodecInfo.isHardwareAcceleratedCompat(): Boolean = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + isHardwareAccelerated + } else { + !isSoftwareOnlyCompat() + } + + private fun MediaCodecInfo.isSoftwareOnlyCompat(): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) return !isHardwareAccelerated + + val lowerName = name.lowercase() + return when { + lowerName.startsWith("arc.") -> false + lowerName.startsWith("omx.google.") -> true + lowerName.startsWith("omx.ffmpeg.") -> true + lowerName.startsWith("c2.android.") -> true + lowerName.startsWith("c2.google.") -> true + lowerName.startsWith("omx.sec.") && lowerName.contains(".sw.") -> true + lowerName == "omx.qcom.video.decoder.hevcswvdec" -> true + !lowerName.startsWith("omx.") && !lowerName.startsWith("c2.") -> true + else -> false + } + } + + private fun getCodecScore(name: String): Int = when (name.lowercase()) { + "c2.android.opus.encoder" -> -1 + "omx.google.opus.encoder" -> -1 + else -> 0 + } + + private fun String.asVendorName(): String = when { + contains("qcom", ignoreCase = true) -> "Qualcomm" + contains("exynos", ignoreCase = true) -> "Exynos" + contains("mtk", ignoreCase = true) || contains("mediatek", ignoreCase = true) -> "MediaTek" + contains("nvidia", ignoreCase = true) -> "NVIDIA" + contains("intel", ignoreCase = true) -> "Intel" + contains("google", ignoreCase = true) -> "Google" + contains("sprd", ignoreCase = true) -> "Spreadtrum" + else -> "Generic" + } + + internal fun MediaCodecInfo.AudioCapabilities.getBitRateInKbits(): ClosedRange { + val supported = bitrateRange ?: Range(6_000, 510_000) + val min = floor(supported.lower / 1000f).toInt().coerceAtLeast(6) + val max = ceil(supported.upper / 1000f).toInt().coerceIn(6, 510) + return Range(min, max).toClosedRange() + } +} diff --git a/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/audio/MjpegAudioModels.kt b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/audio/MjpegAudioModels.kt new file mode 100644 index 00000000..6425fd3e --- /dev/null +++ b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/audio/MjpegAudioModels.kt @@ -0,0 +1,27 @@ +package info.dvkr.screenstream.mjpeg.internal.audio + +import android.media.MediaCodecInfo +import androidx.compose.runtime.Immutable + +internal const val OPUS_MIME_TYPE: String = "audio/opus" +internal const val OPUS_SAMPLE_RATE: Int = 48000 + +internal data class EncodedAudioPacket( + val data: ByteArray, + val durationSamples: Int +) + +internal data class MjpegAudioPacket( + val generation: Long, + val data: ByteArray, + val durationSamples: Int +) + +@Immutable +internal data class MjpegAudioEncoderInfo( + val name: String, + val vendorName: String, + val isHardwareAccelerated: Boolean, + val isCBRModeSupported: Boolean, + val capabilities: MediaCodecInfo.CodecCapabilities +) diff --git a/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/audio/MjpegAudioSource.kt b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/audio/MjpegAudioSource.kt new file mode 100644 index 00000000..e0567d8d --- /dev/null +++ b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/audio/MjpegAudioSource.kt @@ -0,0 +1,48 @@ +package info.dvkr.screenstream.mjpeg.internal.audio + +import android.media.AudioFormat +import android.media.AudioRecord +import kotlin.math.max + +internal interface MjpegAudioSource { + + class Frame(val buffer: ByteArray, val size: Int, val timestampUs: Long) + + data class Params( + val sampleRate: Int, + val isStereo: Boolean, + val bitrate: Int, + val echoCanceler: Boolean = true, + val noiseSuppressor: Boolean = false, + val autoGainControl: Boolean = false + ) { + companion object { + val DEFAULT_OPUS = Params(sampleRate = OPUS_SAMPLE_RATE, isStereo = true, bitrate = 128 * 1000) + } + + @Throws(IllegalArgumentException::class) + internal fun calculateBufferSizeInBytes(desiredBufferDurationMs: Int = 40): Int { + require(desiredBufferDurationMs > 0) { "desiredBufferDurationMs must be positive" } + + val bytesPerSample = 2 + val channelCount = if (isStereo) 2 else 1 + val bytesPerFrame = bytesPerSample * channelCount + + val rawSize = (sampleRate * (desiredBufferDurationMs / 1000.0) * bytesPerFrame).toInt() + val remainder = rawSize % bytesPerFrame + val alignedSize = if (remainder == 0) rawSize else rawSize + bytesPerFrame - remainder + + val channelMask = if (isStereo) AudioFormat.CHANNEL_IN_STEREO else AudioFormat.CHANNEL_IN_MONO + val minBufferSize = AudioRecord.getMinBufferSize(sampleRate, channelMask, AudioFormat.ENCODING_PCM_16BIT) + + return max(alignedSize, minBufferSize) + } + } + + val isRunning: Boolean + + @Throws + fun checkIfConfigurationSupported() + fun start() + fun stop() +} diff --git a/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/audio/MjpegAudioSources.kt b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/audio/MjpegAudioSources.kt new file mode 100644 index 00000000..cbfa9a6f --- /dev/null +++ b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/audio/MjpegAudioSources.kt @@ -0,0 +1,260 @@ +package info.dvkr.screenstream.mjpeg.internal.audio + +import android.Manifest +import android.media.AudioAttributes +import android.media.AudioPlaybackCaptureConfiguration +import android.media.projection.MediaProjection +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.annotation.RequiresPermission +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.nio.ByteBuffer +import java.nio.ByteOrder +import kotlin.math.abs + +internal class MjpegMicrophoneSource( + audioParams: MjpegAudioSource.Params, + private val audioSource: Int, + dispatcher: CoroutineDispatcher, + onAudioFrame: (MjpegAudioSource.Frame) -> Unit, + onCaptureError: (Throwable) -> Unit +) : MjpegAudioSource { + + private val audioCapture = MjpegAudioCapture(audioParams, dispatcher, onAudioFrame, onCaptureError) + + override val isRunning: Boolean + get() = audioCapture.isRunning() + + var volume: Float + get() = audioCapture.volume + set(value) { + audioCapture.volume = value + } + + @Throws + @RequiresPermission(Manifest.permission.RECORD_AUDIO) + override fun checkIfConfigurationSupported() = audioCapture.checkIfConfigurationSupported(audioSource) + + @RequiresPermission(Manifest.permission.RECORD_AUDIO) + override fun start() = audioCapture.start(audioSource) + + override fun stop() = audioCapture.stop() + + internal fun setMute(mute: Boolean) = audioCapture.setMuted(mute) +} + +@RequiresApi(Build.VERSION_CODES.Q) +internal class MjpegInternalAudioSource( + audioParams: MjpegAudioSource.Params, + private val mediaProjection: MediaProjection, + dispatcher: CoroutineDispatcher, + onAudioFrame: (MjpegAudioSource.Frame) -> Unit, + onCaptureError: (Throwable) -> Unit +) : MjpegAudioSource { + + private val audioCapture = MjpegAudioCapture(audioParams, dispatcher, onAudioFrame, onCaptureError) + + override val isRunning: Boolean + get() = audioCapture.isRunning() + + var volume: Float + get() = audioCapture.volume + set(value) { + audioCapture.volume = value + } + + @Throws + @RequiresPermission(Manifest.permission.RECORD_AUDIO) + override fun checkIfConfigurationSupported() { + audioCapture.checkIfConfigurationSupported(createConfig()) + } + + @RequiresPermission(Manifest.permission.RECORD_AUDIO) + override fun start() = audioCapture.start(createConfig()) + + override fun stop() = audioCapture.stop() + + internal fun setMute(mute: Boolean) = audioCapture.setMuted(mute) + + private fun createConfig(): AudioPlaybackCaptureConfiguration = + AudioPlaybackCaptureConfiguration.Builder(mediaProjection) + .addMatchingUsage(AudioAttributes.USAGE_MEDIA) + .addMatchingUsage(AudioAttributes.USAGE_GAME) + .addMatchingUsage(AudioAttributes.USAGE_UNKNOWN) + .build() +} + +@RequiresApi(Build.VERSION_CODES.Q) +internal class MjpegMixAudioSource( + audioParams: MjpegAudioSource.Params, + audioSource: Int, + mediaProjection: MediaProjection, + private val dispatcher: CoroutineDispatcher, + private val onAudioFrame: (MjpegAudioSource.Frame) -> Unit, + private val onCaptureError: (Throwable) -> Unit, + private val micMixFactor: Float = 1.0f, + private val intMixFactor: Float = 0.25f, +) : MjpegAudioSource { + + private val microphone = MjpegMicrophoneSource(audioParams, audioSource, dispatcher, onAudioFrame = { pushToRing(it, micRing) }, onCaptureError) + private val internal = MjpegInternalAudioSource(audioParams, mediaProjection, dispatcher, onAudioFrame = { pushToRing(it, intRing) }, onCaptureError) + + private val channels = if (audioParams.isStereo) 2 else 1 + private val chunkMs = 20 + private val chunkSamplesPerChannel = (audioParams.sampleRate * chunkMs) / 1000 + private val chunkSamples = chunkSamplesPerChannel * channels + private val chunkBytes = chunkSamples * 2 + private val chunkDurationUs = (chunkSamplesPerChannel * 1_000_000L) / audioParams.sampleRate + + private val micRing = ShortRing(chunkSamples * 10) + private val intRing = ShortRing(chunkSamples * 10) + private val limiterTarget = Short.MAX_VALUE * 0.98f + + @Volatile + override var isRunning: Boolean = false + private set + + private var scope: CoroutineScope? = null + + @Throws + @RequiresPermission(Manifest.permission.RECORD_AUDIO) + override fun checkIfConfigurationSupported() { + microphone.checkIfConfigurationSupported() + internal.checkIfConfigurationSupported() + } + + @Synchronized + @RequiresPermission(Manifest.permission.RECORD_AUDIO) + override fun start() { + if (isRunning) return + isRunning = true + + microphone.start() + internal.start() + + val scope = CoroutineScope(SupervisorJob() + dispatcher).also { scope = it } + scope.launch { + val micChunk = ShortArray(chunkSamples) + val intChunk = ShortArray(chunkSamples) + val outChunk = ShortArray(chunkSamples) + val outBytes = ByteArray(chunkBytes) + + var nextPtsUs = MjpegMasterClock.relativeTimeUs() + + while (currentCoroutineContext().isActive && isRunning) { + val micOk = micRing.read(micChunk, chunkSamples) + val intOk = intRing.read(intChunk, chunkSamples) + + if (!micOk) micChunk.fill(0) + if (!intOk) intChunk.fill(0) + + var peakSum = 0f + for (i in 0 until chunkSamples) { + val mixedAbs = abs(micChunk[i] * micMixFactor + intChunk[i] * intMixFactor) + if (mixedAbs > peakSum) peakSum = mixedAbs + } + + val scale = if (peakSum > limiterTarget) limiterTarget / peakSum else 1f + + for (i in 0 until chunkSamples) { + val mixedFloat = (micChunk[i] * micMixFactor + intChunk[i] * intMixFactor) * scale + outChunk[i] = when { + mixedFloat > Short.MAX_VALUE -> Short.MAX_VALUE + mixedFloat < Short.MIN_VALUE -> Short.MIN_VALUE + else -> mixedFloat.toInt().toShort() + } + } + + ByteBuffer.wrap(outBytes).order(ByteOrder.LITTLE_ENDIAN).asShortBuffer().put(outChunk) + onAudioFrame(MjpegAudioSource.Frame(outBytes.clone(), outBytes.size, nextPtsUs)) + + nextPtsUs += chunkDurationUs + val sleepUs = nextPtsUs - MjpegMasterClock.relativeTimeUs() + if (sleepUs > 0) delay((sleepUs / 1000L).coerceAtLeast(1L)) + } + } + } + + @Synchronized + override fun stop() { + if (!isRunning) return + isRunning = false + + microphone.stop() + internal.stop() + + scope?.cancel() + scope = null + + micRing.clear() + intRing.clear() + } + + internal fun setMute(micMute: Boolean, deviceMute: Boolean) { + microphone.setMute(micMute) + internal.setMute(deviceMute) + } + + internal fun setVolume(micVolume: Float, deviceVolume: Float) { + microphone.volume = micVolume + internal.volume = deviceVolume + } + + private fun pushToRing(frame: MjpegAudioSource.Frame, ring: ShortRing) { + val sampleCount = frame.size / 2 + if (sampleCount <= 0) return + val shortBuffer = ByteBuffer.wrap(frame.buffer, 0, sampleCount * 2) + .order(ByteOrder.LITTLE_ENDIAN) + .asShortBuffer() + val tmp = ShortArray(sampleCount) + shortBuffer.get(tmp) + ring.write(tmp, tmp.size) + } + + private class ShortRing(capacitySamples: Int) { + private val data = ShortArray(capacitySamples) + private var head = 0 + private var size = 0 + + @Synchronized + fun write(src: ShortArray, count: Int) { + var i = 0 + while (i < count) { + if (size == data.size) { + head = (head + 1) % data.size + size-- + } + val idx = (head + size) % data.size + data[idx] = src[i] + size++ + i++ + } + } + + @Synchronized + fun read(dst: ShortArray, count: Int): Boolean { + if (size < count) return false + var i = 0 + while (i < count) { + dst[i] = data[head] + head = (head + 1) % data.size + size-- + i++ + } + return true + } + + @Synchronized + fun clear() { + head = 0 + size = 0 + } + } +} diff --git a/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/audio/MjpegMasterClock.kt b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/audio/MjpegMasterClock.kt new file mode 100644 index 00000000..4cfd0449 --- /dev/null +++ b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/audio/MjpegMasterClock.kt @@ -0,0 +1,31 @@ +package info.dvkr.screenstream.mjpeg.internal.audio + +internal object MjpegMasterClock { + @Volatile + private var started = false + + @Volatile + private var startTimeNs: Long = 0L + + @Synchronized + fun ensureStarted() { + if (!started) { + startTimeNs = System.nanoTime() + started = true + } + } + + fun relativeTimeUs(): Long { + ensureStarted() + return (System.nanoTime() - startTimeNs) / 1000L + } + + fun relativeTimeMs(): Long { + ensureStarted() + return (System.nanoTime() - startTimeNs) / 1_000_000L + } + + fun reset() { + started = false + } +} diff --git a/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/audio/OggOpusMuxer.kt b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/audio/OggOpusMuxer.kt new file mode 100644 index 00000000..a3958590 --- /dev/null +++ b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/audio/OggOpusMuxer.kt @@ -0,0 +1,97 @@ +package info.dvkr.screenstream.mjpeg.internal.audio + +import java.nio.ByteBuffer +import java.nio.ByteOrder +import kotlin.random.Random + +internal class OggOpusMuxer( + private val channelCount: Int, + private val serial: Int = Random.nextInt() +) { + private var sequence = 0 + private var granulePosition = 0L + + fun headerPages(): List = listOf( + createPage(createOpusHead(), granule = 0L, headerType = FLAG_BOS), + createPage(createOpusTags(), granule = 0L, headerType = 0) + ) + + fun audioPage(packet: ByteArray, durationSamples: Int): ByteArray { + granulePosition += durationSamples.coerceAtLeast(1) + return createPage(packet, granule = granulePosition, headerType = 0) + } + + fun eosPage(): ByteArray = createPage(ByteArray(0), granule = granulePosition, headerType = FLAG_EOS) + + private fun createOpusHead(): ByteArray = + ByteBuffer.allocate(19) + .order(ByteOrder.LITTLE_ENDIAN) + .put("OpusHead".toByteArray(Charsets.US_ASCII)) + .put(1) + .put(channelCount.toByte()) + .putShort(312) + .putInt(OPUS_SAMPLE_RATE) + .putShort(0) + .put(0) + .array() + + private fun createOpusTags(): ByteArray { + val vendor = "ScreenStream".toByteArray(Charsets.UTF_8) + return ByteBuffer.allocate(8 + 4 + vendor.size + 4) + .order(ByteOrder.LITTLE_ENDIAN) + .put("OpusTags".toByteArray(Charsets.US_ASCII)) + .putInt(vendor.size) + .put(vendor) + .putInt(0) + .array() + } + + private fun createPage(packet: ByteArray, granule: Long, headerType: Int): ByteArray { + val lacing = buildList { + var remaining = packet.size + while (remaining >= 255) { + add(255) + remaining -= 255 + } + add(remaining) + } + require(lacing.size <= 255) { "Ogg packet is too large for one page" } + + val page = ByteArray(27 + lacing.size + packet.size) + val buffer = ByteBuffer.wrap(page).order(ByteOrder.LITTLE_ENDIAN) + buffer.put("OggS".toByteArray(Charsets.US_ASCII)) + buffer.put(0) + buffer.put(headerType.toByte()) + buffer.putLong(granule) + buffer.putInt(serial) + buffer.putInt(sequence++) + buffer.putInt(0) + buffer.put(lacing.size.toByte()) + lacing.forEach { buffer.put(it.toByte()) } + buffer.put(packet) + + val checksum = checksum(page) + ByteBuffer.wrap(page, 22, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(checksum) + return page + } + + private fun checksum(bytes: ByteArray): Int { + var crc = 0 + for (byte in bytes) { + crc = crc xor (byte.toInt() and 0xFF shl 24) + repeat(8) { + crc = if (crc and 0x80000000.toInt() != 0) { + crc shl 1 xor 0x04C11DB7 + } else { + crc shl 1 + } + } + } + return crc + } + + private companion object { + private const val FLAG_BOS = 0x02 + private const val FLAG_EOS = 0x04 + } +} diff --git a/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/mp4/Mp4AudioEncoder.kt b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/mp4/Mp4AudioEncoder.kt new file mode 100644 index 00000000..30e3a44d --- /dev/null +++ b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/mp4/Mp4AudioEncoder.kt @@ -0,0 +1,306 @@ +package info.dvkr.screenstream.mjpeg.internal.mp4 + +import android.media.MediaCodec +import android.media.MediaCodecInfo +import android.media.MediaFormat +import android.media.MediaRecorder +import android.media.projection.MediaProjection +import android.os.Handler +import android.os.HandlerThread +import android.os.Process +import com.elvishew.xlog.XLog +import info.dvkr.screenstream.common.getLog +import info.dvkr.screenstream.mjpeg.internal.audio.MjpegAudioSource +import info.dvkr.screenstream.mjpeg.internal.audio.MjpegInternalAudioSource +import info.dvkr.screenstream.mjpeg.internal.audio.MjpegMicrophoneSource +import info.dvkr.screenstream.mjpeg.internal.audio.MjpegMixAudioSource +import kotlinx.coroutines.CoroutineDispatcher +import java.util.concurrent.ArrayBlockingQueue + +internal class Mp4AudioEncoder( + private val codecInfo: Mp4AudioEncoderInfo, + private val generation: Long, + private val onAudioConfig: (Mp4AudioConfig) -> Unit, + private val onAudioPacket: (Mp4AudioPacket) -> Unit, + private val onAudioCaptureError: (Throwable) -> Unit, + private val onError: (Throwable) -> Unit +) { + private enum class State { IDLE, PREPARED, RUNNING, STOPPED } + + private data class StopSnapshot( + val audioSource: MjpegAudioSource?, + val audioEncoder: MediaCodec?, + val handler: Handler?, + val handlerThread: HandlerThread? + ) + + private val encoderLock = Any() + private var currentState = State.IDLE + private var audioSource: MjpegAudioSource? = null + private var audioEncoder: MediaCodec? = null + private var handlerThread: HandlerThread? = null + private var handler: Handler? = null + private val frameQueue = ArrayBlockingQueue(16) + private val durationSamplesQueue = ArrayBlockingQueue(16) + private var channelCount: Int = 2 + + internal val isCapturing: Boolean + get() = synchronized(encoderLock) { audioSource?.isRunning == true } + + internal fun prepare( + enableMic: Boolean, + enableDeviceAudio: Boolean, + dispatcher: CoroutineDispatcher, + audioParams: MjpegAudioSource.Params, + mediaProjection: MediaProjection? + ) { + var audioSourceConfigurationError: Throwable? = null + runCatching { + synchronized(encoderLock) { + check(currentState == State.IDLE) + channelCount = if (audioParams.isStereo) 2 else 1 + + val onAudioSourceFrame: (MjpegAudioSource.Frame) -> Unit = { audioFrame -> + synchronized(encoderLock) { + if (currentState != State.RUNNING) return@synchronized + if (!frameQueue.offer(audioFrame)) { + XLog.w(getLog("start", "Audio frame queue is full. Dropping frame.")) + } + } + } + + audioSource = when { + enableMic && enableDeviceAudio -> { + val projection = requireNotNull(mediaProjection) { "MediaProjection is required for internal audio capture" } + MjpegMixAudioSource( + audioParams, + MediaRecorder.AudioSource.DEFAULT, + projection, + dispatcher, + onAudioSourceFrame, + onAudioCaptureError + ) + } + + enableMic -> + MjpegMicrophoneSource(audioParams, MediaRecorder.AudioSource.DEFAULT, dispatcher, onAudioSourceFrame, onAudioCaptureError) + + enableDeviceAudio -> { + val projection = requireNotNull(mediaProjection) { "MediaProjection is required for internal audio capture" } + MjpegInternalAudioSource(audioParams, projection, dispatcher, onAudioSourceFrame, onAudioCaptureError) + } + + else -> null + } + + audioSource?.let { + audioSourceConfigurationError = runCatching { it.checkIfConfigurationSupported() }.exceptionOrNull() + if (audioSourceConfigurationError != null) return@synchronized + } + + if (audioSource == null) return + + val format = MediaFormat.createAudioFormat(AAC_MIME_TYPE, audioParams.sampleRate, channelCount).apply { + setInteger(MediaFormat.KEY_BIT_RATE, audioParams.bitrate) + setInteger(MediaFormat.KEY_OPERATING_RATE, audioParams.sampleRate) + setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, audioParams.calculateBufferSizeInBytes() + 64) + setInteger(MediaFormat.KEY_PRIORITY, 1) + setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC) + if (codecInfo.isCBRModeSupported) { + setInteger(MediaFormat.KEY_BITRATE_MODE, MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_CBR) + } else { + setInteger(MediaFormat.KEY_BITRATE_MODE, MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_VBR) + } + } + + val encoder = MediaCodec.createByCodecName(codecInfo.name) + encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) + + handlerThread = HandlerThread("Mp4AudioEncoderHandler", Process.THREAD_PRIORITY_AUDIO).apply { start() } + handler = Handler(handlerThread!!.looper) + encoder.setCallback(createCodecCallback(), handler) + + audioEncoder = encoder + currentState = State.PREPARED + onAudioConfig( + Mp4AudioConfig( + sampleRate = audioParams.sampleRate, + channelCount = channelCount, + audioSpecificConfig = Mp4AudioEncoderUtils.aacLcAudioSpecificConfig(audioParams.sampleRate, channelCount) + ) + ) + } + }.onFailure { cause -> + stopInternal(force = true, logTag = "cleanupAfterPrepareFailure") + onError(cause) + } + + val configurationError = audioSourceConfigurationError ?: return + stopInternal(force = true, logTag = "cleanupAfterPrepareFailure") + onAudioCaptureError(configurationError) + } + + internal fun start(): Unit = synchronized(encoderLock) { + if (audioSource == null || currentState != State.PREPARED) return + audioEncoder?.start() + audioSource!!.start() + currentState = State.RUNNING + } + + internal fun stop() { + stopInternal(force = false, logTag = "stopInternal") + } + + internal fun setMute(micMute: Boolean, deviceMute: Boolean) { + when (val source = audioSource) { + is MjpegMicrophoneSource -> source.setMute(micMute) + is MjpegInternalAudioSource -> source.setMute(deviceMute) + is MjpegMixAudioSource -> source.setMute(micMute, deviceMute) + } + } + + internal fun setVolume(micVolume: Float, deviceVolume: Float) { + when (val source = audioSource) { + is MjpegMicrophoneSource -> source.volume = micVolume + is MjpegInternalAudioSource -> source.volume = deviceVolume + is MjpegMixAudioSource -> source.setVolume(micVolume, deviceVolume) + } + } + + private fun stopInternal(force: Boolean, logTag: String): Boolean { + val snapshot = synchronized(encoderLock) { + if (audioSource == null && !force) return@synchronized null + if (!force && (currentState == State.IDLE || currentState == State.STOPPED)) return@synchronized null + + currentState = State.STOPPED + + StopSnapshot(audioSource, audioEncoder, handler, handlerThread).also { + audioSource = null + audioEncoder = null + handler = null + handlerThread = null + } + } ?: return false + + runCatching { snapshot.audioSource?.stop() } + snapshot.audioEncoder?.runCatching { + stop() + release() + }?.onFailure { + XLog.w(getLog(logTag, "mediaCodec.stop() exception: ${it.message}"), it) + } + + snapshot.handler?.removeCallbacksAndMessages(null) + snapshot.handlerThread?.apply { + quitSafely() + runCatching { join(250) }.onFailure { XLog.w(getLog(logTag, "handlerThread.join() interrupted"), it) } + if (isAlive) { + quit() + runCatching { join(250) } + } + } + + frameQueue.clear() + durationSamplesQueue.clear() + + synchronized(encoderLock) { + currentState = State.IDLE + } + return true + } + + private fun isCallbackCodecActive(codec: MediaCodec): Boolean = synchronized(encoderLock) { + codec === audioEncoder && currentState == State.RUNNING + } + + private fun createCodecCallback(): MediaCodec.Callback = object : MediaCodec.Callback() { + override fun onInputBufferAvailable(codec: MediaCodec, index: Int) { + runCatching { + synchronized(encoderLock) { + if (codec !== audioEncoder) return + if (currentState != State.RUNNING) { + runCatching { codec.queueInputBuffer(index, 0, 0, 0, 0) } + return + } + + val frame = frameQueue.poll() ?: run { + runCatching { codec.queueInputBuffer(index, 0, 0, 0, 0) } + return + } + + val inputBuffer = codec.getInputBuffer(index) ?: run { + runCatching { codec.queueInputBuffer(index, 0, 0, 0, 0) } + return + } + + inputBuffer.clear() + if (inputBuffer.remaining() < frame.size) { + runCatching { codec.queueInputBuffer(index, 0, 0, 0, 0) } + throw IllegalArgumentException("Frame too large for input buffer") + } + + inputBuffer.put(frame.buffer, 0, frame.size) + val samplesPerChannel = (frame.size / 2 / channelCount).coerceAtLeast(1) + durationSamplesQueue.offer(samplesPerChannel) + codec.queueInputBuffer(index, 0, frame.size, frame.timestampUs, 0) + } + }.onFailure { cause -> + if (!isCallbackCodecActive(codec)) return@onFailure + XLog.w(getLog("CodecCallback.onInputBufferAvailable", "onFailure: ${cause.message}"), cause) + onError(cause) + } + } + + override fun onOutputBufferAvailable(codec: MediaCodec, index: Int, info: MediaCodec.BufferInfo) { + runCatching { + val packet = synchronized(encoderLock) { + if (codec !== audioEncoder || currentState != State.RUNNING || info.size == 0 || + info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0 + ) { + runCatching { codec.releaseOutputBuffer(index, false) } + return + } + + val outputBuffer = codec.getOutputBuffer(index) ?: run { + runCatching { codec.releaseOutputBuffer(index, false) } + return + } + + outputBuffer.position(info.offset) + outputBuffer.limit(info.offset + info.size) + val data = ByteArray(info.size) + outputBuffer.get(data) + val durationSamples = durationSamplesQueue.poll() ?: 1024 + val timestampUs = info.presentationTimeUs + codec.releaseOutputBuffer(index, false) + + Mp4AudioPacket( + generation = generation, + data = data, + timestampUs = timestampUs, + durationSamples = durationSamples + ) + } + onAudioPacket(packet) + }.onFailure { cause -> + runCatching { codec.releaseOutputBuffer(index, false) } + if (!isCallbackCodecActive(codec)) return@onFailure + XLog.w(getLog("CodecCallback.onOutputBufferAvailable", "onFailure: ${cause.message}"), cause) + onError(cause) + } + } + + override fun onError(codec: MediaCodec, cause: MediaCodec.CodecException) { + if (!isCallbackCodecActive(codec)) return + XLog.w(getLog("CodecCallback.onError", "onFailure: ${cause.message}"), cause) + onError(cause) + } + + override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat) { + if (!isCallbackCodecActive(codec)) return + val csd = format.getByteBuffer("csd-0") ?: return + val data = ByteArray(csd.remaining()).also { csd.get(it) } + onAudioConfig(Mp4AudioConfig(AAC_SAMPLE_RATE, channelCount, data)) + } + } +} diff --git a/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/mp4/Mp4AudioEncoderUtils.kt b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/mp4/Mp4AudioEncoderUtils.kt new file mode 100644 index 00000000..91b2f889 --- /dev/null +++ b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/mp4/Mp4AudioEncoderUtils.kt @@ -0,0 +1,67 @@ +package info.dvkr.screenstream.mjpeg.internal.mp4 + +import android.media.MediaCodecInfo +import android.media.MediaCodecInfo.EncoderCapabilities +import android.media.MediaCodecList + +internal const val AAC_MIME_TYPE: String = "audio/mp4a-latm" +internal const val AAC_SAMPLE_RATE: Int = 48_000 + +internal data class Mp4AudioEncoderInfo( + val name: String, + val isCBRModeSupported: Boolean, + val capabilities: MediaCodecInfo.CodecCapabilities +) + +internal object Mp4AudioEncoderUtils { + val selectedAacEncoder: Mp4AudioEncoderInfo? + get() = availableAacEncoders.firstOrNull() + + private val availableAacEncoders: List by lazy { + MediaCodecList(MediaCodecList.ALL_CODECS).codecInfos + .filter { it.isEncoder } + .filter { encoder -> encoder.supportedTypes.any { it.equals(AAC_MIME_TYPE, ignoreCase = true) } } + .sortedBy { getCodecScore(it.name) } + .map { encoder -> + Mp4AudioEncoderInfo( + name = encoder.name, + isCBRModeSupported = encoder.isCbrCapable(AAC_MIME_TYPE), + capabilities = encoder.getCapabilitiesForType(AAC_MIME_TYPE) + ) + } + .sortedWith(compareBy({ if (it.isCBRModeSupported) 0 else 1 }, { getCodecScore(it.name) })) + } + + internal fun aacLcAudioSpecificConfig(sampleRate: Int, channelCount: Int): ByteArray { + val sampleRateIndex = when (sampleRate) { + 96_000 -> 0 + 88_200 -> 1 + 64_000 -> 2 + 48_000 -> 3 + 44_100 -> 4 + 32_000 -> 5 + 24_000 -> 6 + 22_050 -> 7 + 16_000 -> 8 + 12_000 -> 9 + 11_025 -> 10 + 8_000 -> 11 + else -> 3 + } + val config = ((2 and 0x1F) shl 11) or ((sampleRateIndex and 0x0F) shl 7) or ((channelCount and 0x0F) shl 3) + return byteArrayOf((config ushr 8).toByte(), config.toByte()) + } + + private fun MediaCodecInfo.isCbrCapable(mimeType: String): Boolean = + runCatching { + getCapabilitiesForType(mimeType) + ?.encoderCapabilities + ?.isBitrateModeSupported(EncoderCapabilities.BITRATE_MODE_CBR) ?: false + }.getOrDefault(false) + + private fun getCodecScore(name: String): Int = when (name.lowercase()) { + "c2.sec.aac.encoder" -> -2 + "omx.google.aac.encoder" -> -1 + else -> 0 + } +} diff --git a/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/mp4/Mp4BoxWriter.kt b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/mp4/Mp4BoxWriter.kt new file mode 100644 index 00000000..9d82c9af --- /dev/null +++ b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/mp4/Mp4BoxWriter.kt @@ -0,0 +1,475 @@ +package info.dvkr.screenstream.mjpeg.internal.mp4 + +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +import java.nio.ByteOrder + +internal object Mp4BoxWriter { + private const val MOVIE_TIMESCALE = 1000 + private const val VIDEO_TRACK_ID = 1 + private const val AUDIO_TRACK_ID = 2 + private const val VIDEO_TIMESCALE = 90_000 + + internal fun initSegment(config: Mp4StreamConfig): ByteArray = + concat( + ftyp(), + moov(config) + ) + + internal fun videoSegment(sequence: Int, packet: Mp4VideoPacket, timestampOffsetUs: Long = 0L): ByteArray { + val baseTime = (packet.timestampUs - timestampOffsetUs).coerceAtLeast(0L).usToScale(VIDEO_TIMESCALE) + val duration = packet.durationUs.usToScale(VIDEO_TIMESCALE).coerceAtLeast(1L) + val flags = if (packet.isKeyFrame) SAMPLE_DEPENDS_ON_OTHERS.notSampleFlags() else SAMPLE_NON_SYNC_FLAGS + return mediaSegment( + sequence = sequence, + trackId = VIDEO_TRACK_ID, + baseMediaDecodeTime = baseTime, + sampleDuration = duration, + sampleData = packet.data, + sampleFlags = flags + ) + } + + internal fun audioSegment(sequence: Int, packet: Mp4AudioPacket, timestampOffsetUs: Long = 0L): ByteArray = + mediaSegment( + sequence = sequence, + trackId = AUDIO_TRACK_ID, + baseMediaDecodeTime = (packet.timestampUs - timestampOffsetUs).coerceAtLeast(0L).usToScale(AAC_SAMPLE_RATE_SCALE), + sampleDuration = packet.durationSamples.toLong().coerceAtLeast(1L), + sampleData = packet.data.stripAdtsHeader(), + sampleFlags = SAMPLE_DEPENDS_ON_OTHERS.notSampleFlags() + ) + + private fun ftyp(): ByteArray = + box( + "ftyp", + concat( + ascii("iso6"), + u32(1), + ascii("iso6"), + ascii("mp41"), + ascii("avc1"), + ascii("dash"), + ascii("iso5") + ) + ) + + private fun moov(config: Mp4StreamConfig): ByteArray = + box( + "moov", + buildList { + add(mvhd(nextTrackId = if (config.audio != null) AUDIO_TRACK_ID + 1 else VIDEO_TRACK_ID + 1)) + config.video?.let { add(videoTrak(it)) } + config.audio?.let { add(audioTrak(it)) } + add(mvex(config)) + }.concat() + ) + + private fun mvhd(nextTrackId: Int): ByteArray = + fullBox( + "mvhd", + version = 0, + flags = 0, + payload = concat( + u32(0), // creation_time + u32(0), // modification_time + u32(MOVIE_TIMESCALE), + u32(0), // duration unknown for live stream + u32(0x00010000), // rate 1.0 + u16(0x0100), // volume 1.0 + u16(0), + u32(0), + u32(0), + unityMatrix(), + ByteArray(24), + u32(nextTrackId) + ) + ) + + private fun videoTrak(config: Mp4VideoConfig): ByteArray = + box( + "trak", + concat( + tkhd(VIDEO_TRACK_ID, volume = 0, width = config.width, height = config.height), + mdia( + timescale = VIDEO_TIMESCALE, + handler = "vide", + mediaHeader = vmhd(), + sampleDescription = avc1(config) + ) + ) + ) + + private fun audioTrak(config: Mp4AudioConfig): ByteArray = + box( + "trak", + concat( + tkhd(AUDIO_TRACK_ID, volume = 0x0100, width = 0, height = 0), + mdia( + timescale = config.sampleRate, + handler = "soun", + mediaHeader = smhd(), + sampleDescription = mp4a(config) + ) + ) + ) + + private fun tkhd(trackId: Int, volume: Int, width: Int, height: Int): ByteArray = + fullBox( + "tkhd", + version = 0, + flags = 0x000007, + payload = concat( + u32(0), // creation_time + u32(0), // modification_time + u32(trackId), + u32(0), + u32(0), // duration unknown for live stream + u32(0), + u32(0), + u16(0), // layer + u16(0), // alternate_group + u16(volume), + u16(0), + unityMatrix(), + u32(width shl 16), + u32(height shl 16) + ) + ) + + private fun mdia(timescale: Int, handler: String, mediaHeader: ByteArray, sampleDescription: ByteArray): ByteArray = + box( + "mdia", + concat( + mdhd(timescale), + hdlr(handler), + minf(mediaHeader, sampleDescription) + ) + ) + + private fun mdhd(timescale: Int): ByteArray = + fullBox( + "mdhd", + version = 0, + flags = 0, + payload = concat( + u32(0), + u32(0), + u32(timescale), + u32(0), + u16(0x55C4), // und + u16(0) + ) + ) + + private fun hdlr(handler: String): ByteArray { + val name = if (handler == "vide") "VideoHandler" else "SoundHandler" + return fullBox( + "hdlr", + version = 0, + flags = 0, + payload = concat( + u32(0), + ascii(handler), + u32(0), + u32(0), + u32(0), + ascii(name), + byteArrayOf(0) + ) + ) + } + + private fun vmhd(): ByteArray = + fullBox( + "vmhd", + version = 0, + flags = 0x000001, + payload = concat(u16(0), u16(0), u16(0), u16(0)) + ) + + private fun smhd(): ByteArray = + fullBox("smhd", version = 0, flags = 0, payload = concat(u16(0), u16(0))) + + private fun minf(mediaHeader: ByteArray, sampleDescription: ByteArray): ByteArray = + box( + "minf", + concat( + mediaHeader, + dinf(), + stbl(sampleDescription) + ) + ) + + private fun dinf(): ByteArray = + box( + "dinf", + fullBox( + "dref", + version = 0, + flags = 0, + payload = concat( + u32(1), + fullBox("url ", version = 0, flags = 0x000001, payload = ByteArray(0)) + ) + ) + ) + + private fun stbl(sampleDescription: ByteArray): ByteArray = + box( + "stbl", + concat( + stsd(sampleDescription), + fullBox("stts", 0, 0, u32(0)), + fullBox("stsc", 0, 0, u32(0)), + fullBox("stsz", 0, 0, concat(u32(0), u32(0))), + fullBox("stco", 0, 0, u32(0)) + ) + ) + + private fun stsd(sampleDescription: ByteArray): ByteArray = + fullBox("stsd", version = 0, flags = 0, payload = concat(u32(1), sampleDescription)) + + private fun avc1(config: Mp4VideoConfig): ByteArray = + box( + "avc1", + concat( + ByteArray(6), + u16(1), // data_reference_index + u16(0), + u16(0), + u32(0), + u32(0), + u32(0), + u16(config.width), + u16(config.height), + u32(0x00480000), + u32(0x00480000), + u32(0), + u16(1), + compressorName("ScreenStream"), + u16(0x0018), + u16(0xFFFF), + avcC(config) + ) + ) + + private fun avcC(config: Mp4VideoConfig): ByteArray { + val sps = config.sps + val pps = config.pps + val profile = if (sps.size > 1) sps[1] else 0x42 + val compatibility = if (sps.size > 2) sps[2] else 0x00 + val level = if (sps.size > 3) sps[3] else 0x1F + return box( + "avcC", + concat( + byteArrayOf(1, profile, compatibility, level), + byteArrayOf(0xFF.toByte()), // 4-byte NAL lengths + byteArrayOf(0xE1.toByte()), // one SPS + u16(sps.size), + sps, + byteArrayOf(1), // one PPS + u16(pps.size), + pps + ) + ) + } + + private fun mp4a(config: Mp4AudioConfig): ByteArray = + box( + "mp4a", + concat( + ByteArray(6), + u16(1), // data_reference_index + u32(0), + u32(0), + u16(config.channelCount), + u16(16), + u16(0), + u16(0), + u32(config.sampleRate shl 16), + esds(config) + ) + ) + + private fun esds(config: Mp4AudioConfig): ByteArray { + val decoderSpecific = descriptor(0x05, config.audioSpecificConfig) + val decoderConfig = descriptor( + 0x04, + concat( + byteArrayOf(0x40), // MPEG-4 Audio + byteArrayOf(0x15), // AudioStream + byteArrayOf(0, 0, 0), // bufferSizeDB + u32(0), + u32(0), + decoderSpecific + ) + ) + val slConfig = descriptor(0x06, byteArrayOf(0x02)) + val esDescriptor = descriptor(0x03, concat(u16(1), byteArrayOf(0), decoderConfig, slConfig)) + return fullBox("esds", version = 0, flags = 0, payload = esDescriptor) + } + + private fun descriptor(tag: Int, payload: ByteArray): ByteArray { + require(payload.size < 128) { "Descriptor payload too large: ${payload.size}" } + return concat(byteArrayOf(tag.toByte(), payload.size.toByte()), payload) + } + + private fun mvex(config: Mp4StreamConfig): ByteArray = + box( + "mvex", + buildList { + config.video?.let { add(trex(VIDEO_TRACK_ID)) } + config.audio?.let { add(trex(AUDIO_TRACK_ID)) } + }.concat() + ) + + private fun trex(trackId: Int): ByteArray = + fullBox( + "trex", + version = 0, + flags = 0, + payload = concat( + u32(trackId), + u32(1), + u32(0), + u32(0), + u32(0) + ) + ) + + private fun mediaSegment( + sequence: Int, + trackId: Int, + baseMediaDecodeTime: Long, + sampleDuration: Long, + sampleData: ByteArray, + sampleFlags: Int + ): ByteArray { + val trafWithPlaceholder = traf(trackId, baseMediaDecodeTime, sampleDuration, sampleData.size, sampleFlags, dataOffset = 0) + val moofWithPlaceholder = moof(sequence, trafWithPlaceholder) + val dataOffset = moofWithPlaceholder.size + 8 + val moof = moof(sequence, traf(trackId, baseMediaDecodeTime, sampleDuration, sampleData.size, sampleFlags, dataOffset)) + return concat(moof, box("mdat", sampleData)) + } + + private fun moof(sequence: Int, traf: ByteArray): ByteArray = + box("moof", concat(mfhd(sequence), traf)) + + private fun mfhd(sequence: Int): ByteArray = + fullBox("mfhd", version = 0, flags = 0, payload = u32(sequence)) + + private fun traf( + trackId: Int, + baseMediaDecodeTime: Long, + sampleDuration: Long, + sampleSize: Int, + sampleFlags: Int, + dataOffset: Int + ): ByteArray = + box( + "traf", + concat( + tfhd(trackId), + tfdt(baseMediaDecodeTime), + trun(sampleDuration, sampleSize, sampleFlags, dataOffset) + ) + ) + + private fun tfhd(trackId: Int): ByteArray = + fullBox("tfhd", version = 0, flags = 0x020000, payload = u32(trackId)) + + private fun tfdt(baseMediaDecodeTime: Long): ByteArray = + fullBox("tfdt", version = 1, flags = 0, payload = u64(baseMediaDecodeTime)) + + private fun trun(sampleDuration: Long, sampleSize: Int, sampleFlags: Int, dataOffset: Int): ByteArray = + fullBox( + "trun", + version = 0, + flags = 0x000001 or 0x000100 or 0x000200 or 0x000400, + payload = concat( + u32(1), + u32(dataOffset), + u32(sampleDuration), + u32(sampleSize), + u32(sampleFlags) + ) + ) + + private fun compressorName(name: String): ByteArray { + val bytes = name.encodeToByteArray().take(31).toByteArray() + return ByteArray(32).also { + it[0] = bytes.size.toByte() + bytes.copyInto(it, destinationOffset = 1) + } + } + + private fun fullBox(type: String, version: Int, flags: Int, payload: ByteArray): ByteArray = + box( + type, + concat( + byteArrayOf(version.toByte(), (flags shr 16).toByte(), (flags shr 8).toByte(), flags.toByte()), + payload + ) + ) + + private fun box(type: String, payload: ByteArray): ByteArray { + require(type.length == 4) { "MP4 box type must be 4 chars: $type" } + return ByteBuffer.allocate(payload.size + 8) + .order(ByteOrder.BIG_ENDIAN) + .putInt(payload.size + 8) + .put(ascii(type)) + .put(payload) + .array() + } + + private fun unityMatrix(): ByteArray = + concat( + u32(0x00010000), + u32(0), + u32(0), + u32(0), + u32(0x00010000), + u32(0), + u32(0), + u32(0), + u32(0x40000000) + ) + + private fun ByteArray.stripAdtsHeader(): ByteArray { + if (size < 7) return this + val b0 = this[0].toInt() and 0xFF + val b1 = this[1].toInt() and 0xFF + if (b0 != 0xFF || (b1 and 0xF0) != 0xF0) return this + val protectionAbsent = (b1 and 0x01) == 1 + val headerSize = if (protectionAbsent) 7 else 9 + return if (size > headerSize) copyOfRange(headerSize, size) else ByteArray(0) + } + + private fun Long.usToScale(timescale: Int): Long = (this * timescale) / 1_000_000L + + private fun Int.notSampleFlags(): Int = this shl 24 + + private fun u16(value: Int): ByteArray = + ByteBuffer.allocate(2).order(ByteOrder.BIG_ENDIAN).putShort(value.toShort()).array() + + private fun u32(value: Int): ByteArray = + ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putInt(value).array() + + private fun u32(value: Long): ByteArray = + ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putInt(value.toInt()).array() + + private fun u64(value: Long): ByteArray = + ByteBuffer.allocate(8).order(ByteOrder.BIG_ENDIAN).putLong(value).array() + + private fun ascii(value: String): ByteArray = value.toByteArray(Charsets.US_ASCII) + + private fun concat(vararg arrays: ByteArray): ByteArray = arrays.asList().concat() + + private fun List.concat(): ByteArray = + ByteArrayOutputStream(sumOf { it.size }).also { output -> forEach { output.write(it) } }.toByteArray() + + private const val AAC_SAMPLE_RATE_SCALE = 48_000 + private const val SAMPLE_DEPENDS_ON_OTHERS = 2 + private const val SAMPLE_NON_SYNC_FLAGS = 0x01010000 +} diff --git a/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/mp4/Mp4Capture.kt b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/mp4/Mp4Capture.kt new file mode 100644 index 00000000..c95034c3 --- /dev/null +++ b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/mp4/Mp4Capture.kt @@ -0,0 +1,214 @@ +package info.dvkr.screenstream.mjpeg.internal.mp4 + +import android.content.Context +import android.hardware.display.DisplayManager +import android.hardware.display.VirtualDisplay +import android.media.projection.MediaProjection +import android.view.Surface +import androidx.core.util.toClosedRange +import androidx.window.layout.WindowMetricsCalculator +import com.elvishew.xlog.XLog +import info.dvkr.screenstream.common.getLog +import info.dvkr.screenstream.mjpeg.internal.audio.MjpegAudioSource +import info.dvkr.screenstream.mjpeg.settings.MjpegSettings +import info.dvkr.screenstream.mjpeg.ui.MjpegError +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlin.math.roundToInt + +internal class Mp4Capture( + private val serviceContext: Context, + private val settings: MjpegSettings.Data, + private val mediaProjection: MediaProjection?, + private val generation: Long, + private val enableMic: Boolean, + private val enableDeviceAudio: Boolean, + private val dispatcher: CoroutineDispatcher, + private val configFlow: MutableStateFlow, + private val videoPacketFlow: MutableSharedFlow, + private val audioPacketFlow: MutableSharedFlow, + private val onError: (MjpegError) -> Unit, + private val onAudioCaptureError: (Throwable) -> Unit +) { + private var virtualDisplay: VirtualDisplay? = null + private var captureSurface: Surface? = null + private var videoEncoder: Mp4VideoEncoder? = null + private var audioEncoder: Mp4AudioEncoder? = null + private var videoConfig: Mp4VideoConfig? = null + private var audioConfig: Mp4AudioConfig? = null + private var started = false + + private val streamAudioOnly: Boolean = settings.streamAudioOnly + private val audioEnabled: Boolean = enableMic || enableDeviceAudio + + internal fun start(isStartupStillValid: () -> Boolean): Boolean { + check(!started) { "MP4 capture already started" } + started = true + configFlow.value = null + + runCatching { + if (!streamAudioOnly) startVideo(isStartupStillValid) + if (audioEnabled) startAudio() + publishConfigIfReady() + }.onFailure { cause -> + XLog.w(getLog("start", "Failed to start MP4 capture: ${cause.message}"), cause) + stop() + if (cause is MjpegError) onError(cause) else onError(MjpegError.UnknownError(cause)) + } + + return if (streamAudioOnly) audioEncoder?.isCapturing == true else virtualDisplay != null && videoEncoder != null + } + + internal fun stop() { + videoEncoder?.stop() + videoEncoder = null + + virtualDisplay?.surface = null + virtualDisplay?.release() + virtualDisplay = null + + runCatching { captureSurface?.release() } + captureSurface = null + + audioEncoder?.stop() + audioEncoder = null + + videoConfig = null + audioConfig = null + configFlow.value = null + started = false + } + + internal fun setMute(micMute: Boolean, deviceMute: Boolean) { + audioEncoder?.setMute(micMute, deviceMute) + } + + internal fun setVolume(micVolume: Float, deviceVolume: Float) { + audioEncoder?.setVolume(micVolume, deviceVolume) + } + + internal fun setVideoBitrate(videoBitrateBits: Int) { + videoEncoder?.setBitrate(videoBitrateBits) + } + + private fun startVideo(isStartupStillValid: () -> Boolean) { + val encoderInfo = requireNotNull( + Mp4VideoEncoderUtils.selectH264Encoder(settings.videoCodecAutoSelect, settings.videoCodec) + ) { "No H.264 video encoder available" } + val videoCapabilities = requireNotNull(encoderInfo.capabilities.videoCapabilities) { "Missing H.264 video capabilities" } + val bounds = WindowMetricsCalculator.getOrCreate().computeMaximumWindowMetrics(serviceContext).bounds + val (width, height) = with(Mp4VideoEncoderUtils) { + videoCapabilities.adjustSize( + sourceWidth = bounds.width(), + sourceHeight = bounds.height(), + resizeFactor = settings.resizeFactor / 100F, + exactWidth = settings.resolutionWidth, + exactHeight = settings.resolutionHeight, + stretch = settings.resolutionStretch + ) + } + val requestedFps = when { + settings.maxFPS > 0 -> settings.maxFPS + settings.maxFPS < 0 -> (1F / -settings.maxFPS).roundToInt().coerceAtLeast(1) + else -> MjpegSettings.Default.MAX_FPS + } + val fps = requestedFps.coerceIn(videoCapabilities.supportedFrameRates.toClosedRange()) + val bitrate = settings.videoBitrateBits.coerceIn(videoCapabilities.bitrateRange.toClosedRange()) + + val encoder = Mp4VideoEncoder( + codecInfo = encoderInfo, + onVideoConfig = { sps, pps -> + videoConfig = Mp4VideoConfig(width, height, sps, pps, fps) + publishConfigIfReady() + }, + onVideoPacket = { packet -> videoPacketFlow.tryEmit(packet) }, + onError = { + XLog.w(getLog("VideoEncoder.onError", it.message), it) + onError(MjpegError.UnknownError(it)) + } + ).also { videoEncoder = it } + + encoder.prepare(width, height, fps, bitrate, generation) + if (!isStartupStillValid()) { + encoder.stop() + return + } + + val inputSurfaceTexture = encoder.inputSurfaceTexture ?: throw IllegalStateException("H.264 encoder input surface is null") + val surface = Surface(inputSurfaceTexture) + captureSurface = surface + virtualDisplay = requireNotNull(mediaProjection) { "MediaProjection is required for MP4 video capture" }.createVirtualDisplay( + "Mp4CaptureVirtualDisplay", + width, + height, + serviceContext.resources.configuration.densityDpi, + DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, + surface, + null, + null + ) + + if (virtualDisplay == null || !isStartupStillValid()) { + encoder.stop() + runCatching { surface.release() } + captureSurface = null + virtualDisplay?.release() + virtualDisplay = null + return + } + + encoder.start() + } + + private fun startAudio() { + val encoderInfo = requireNotNull(Mp4AudioEncoderUtils.selectedAacEncoder) { "No AAC audio encoder available" } + val params = MjpegAudioSource.Params.DEFAULT_OPUS.copy( + sampleRate = AAC_SAMPLE_RATE, + bitrate = settings.audioBitrateBits, + echoCanceler = settings.audioEchoCanceller, + noiseSuppressor = settings.audioNoiseSuppressor, + isStereo = true + ) + + val encoder = Mp4AudioEncoder( + codecInfo = encoderInfo, + generation = generation, + onAudioConfig = { config -> + audioConfig = config + publishConfigIfReady() + }, + onAudioPacket = { packet -> audioPacketFlow.tryEmit(packet) }, + onAudioCaptureError = onAudioCaptureError, + onError = { + XLog.w(getLog("AudioEncoder.onError", it.message), it) + onError(MjpegError.UnknownError(it)) + } + ).also { audioEncoder = it } + + encoder.prepare( + enableMic = enableMic, + enableDeviceAudio = enableDeviceAudio, + dispatcher = dispatcher, + audioParams = params, + mediaProjection = mediaProjection + ) + encoder.setMute(settings.muteMic, settings.muteDeviceAudio) + encoder.setVolume(settings.volumeMic, settings.volumeDeviceAudio) + encoder.start() + check(encoder.isCapturing) { "AAC audio capture did not start" } + } + + private fun publishConfigIfReady() { + val video = videoConfig + val audio = audioConfig + if (!streamAudioOnly && video == null) return + if (audioEnabled && audio == null) return + configFlow.value = Mp4StreamConfig( + generation = generation, + video = if (streamAudioOnly) null else video, + audio = if (audioEnabled) audio else null + ) + } + +} diff --git a/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/mp4/Mp4EglRenderer.kt b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/mp4/Mp4EglRenderer.kt new file mode 100644 index 00000000..e4123171 --- /dev/null +++ b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/mp4/Mp4EglRenderer.kt @@ -0,0 +1,375 @@ +package info.dvkr.screenstream.mjpeg.internal.mp4 + +import android.graphics.SurfaceTexture +import android.opengl.EGL14 +import android.opengl.EGLConfig +import android.opengl.EGLContext +import android.opengl.EGLDisplay +import android.opengl.EGLSurface +import android.opengl.GLES11Ext +import android.opengl.GLES20 +import android.opengl.Matrix +import android.os.Handler +import android.os.HandlerThread +import android.os.Process +import android.view.Surface +import com.elvishew.xlog.XLog +import info.dvkr.screenstream.common.getLog +import info.dvkr.screenstream.mjpeg.internal.audio.MjpegMasterClock +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.FloatBuffer +import java.util.concurrent.CountDownLatch +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.math.roundToLong +import kotlin.system.measureTimeMillis + +internal class Mp4EglRenderer( + private val width: Int, + private val height: Int, + private val encoderSurface: Surface, + private val onError: (Throwable) -> Unit +) { + private companion object { + private const val RECORDABLE_ANDROID = 0x3142 + + private val MVP_MATRIX = FloatArray(16).apply { + Matrix.setIdentityM(this, 0) + Matrix.scaleM(this, 0, 1f, -1f, 1f) + } + + private val FULL_RECT_VERTICES_BUFFER: FloatBuffer = + ByteBuffer.allocateDirect(8 * 4) + .order(ByteOrder.nativeOrder()) + .asFloatBuffer() + .apply { + put(floatArrayOf(-1.0f, -1.0f, 1.0f, -1.0f, -1.0f, 1.0f, 1.0f, 1.0f)) + position(0) + } + + private val FULL_RECT_TEX_BUFFER: FloatBuffer = + ByteBuffer.allocateDirect(8 * 4) + .order(ByteOrder.nativeOrder()) + .asFloatBuffer() + .apply { + put(floatArrayOf(0.0f, 1.0f, 1.0f, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f)) + position(0) + } + + private const val VERTEX_SHADER = """ + attribute vec4 aPosition; + attribute vec4 aTextureCoord; + uniform mat4 uMVPMatrix; + uniform mat4 uSTMatrix; + varying vec2 vTextureCoord; + void main() { + gl_Position = uMVPMatrix * aPosition; + vTextureCoord = (uSTMatrix * aTextureCoord).xy; + } + """ + + private const val FRAGMENT_SHADER = """ + #extension GL_OES_EGL_image_external : require + precision mediump float; + varying vec2 vTextureCoord; + uniform samplerExternalOES sTexture; + void main() { + gl_FragColor = texture2D(sTexture, vTextureCoord); + } + """ + } + + private val isRendering = AtomicBoolean(false) + private val errorOccurred = AtomicBoolean(false) + private val handlerThread: HandlerThread = HandlerThread("Mp4EglRendererThread", Process.THREAD_PRIORITY_DISPLAY).apply { start() } + private val handler: Handler = Handler(handlerThread.looper) + + private var eglDisplay: EGLDisplay = EGL14.EGL_NO_DISPLAY + private var eglContext: EGLContext = EGL14.EGL_NO_CONTEXT + private var eglSurface: EGLSurface = EGL14.EGL_NO_SURFACE + + @Volatile private var surfaceTexture: SurfaceTexture? = null + private var textureId = -1 + private var programId = 0 + + private var uMVPMatrixLoc = -1 + private var uSTMatrixLoc = -1 + private var aPositionHandle = -1 + private var aTextureCoordHandle = -1 + + private val stMatrix = FloatArray(16) + private val renderFrameTask = Runnable { renderFrame() } + private var fps = 30 + private var frameIntervalMs = 1000.0 / fps + private var nextRenderTimeMs = 0.0 + + internal val inputSurfaceTexture: SurfaceTexture + get() = surfaceTexture ?: throw IllegalStateException("SurfaceTexture is not initialized.") + + init { + val latch = CountDownLatch(1) + handler.post { + runSafely { + eglSetup(encoderSurface) + createGLObjects() + } + latch.countDown() + } + latch.await() + } + + internal fun startAsync() { + if (isRendering.compareAndSet(false, true)) handler.post(renderFrameTask) + } + + internal fun stop() { + isRendering.set(false) + val isHandlerThread = Thread.currentThread() == handlerThread.looper?.thread + if (isHandlerThread) { + stopOnHandlerThread() + } else { + val latch = CountDownLatch(1) + val posted = handler.post { + runCatching { stopOnHandlerThread() } + .onFailure { XLog.e(getLog("stop", "Failed to stop on handler thread."), it) } + latch.countDown() + } + if (posted) latch.await() + } + + runCatching { encoderSurface.release() }.onFailure { XLog.e(getLog("stop", "Failed to release encoder surface."), it) } + } + + internal fun setFps(fps: Int) { + handler.post { + this.fps = fps.coerceAtLeast(1) + frameIntervalMs = 1000.0 / this.fps + } + } + + private fun stopOnHandlerThread() { + handler.removeCallbacksAndMessages(null) + val surfaceTextureToRelease = surfaceTexture + surfaceTextureToRelease?.setOnFrameAvailableListener(null) + surfaceTexture = null + runCatching { releaseGL(surfaceTextureToRelease) }.onFailure { XLog.e(getLog("stop", "Failed to release GL resources."), it) } + handlerThread.quitSafely() + } + + private inline fun runSafely(block: () -> Unit) { + if (errorOccurred.get()) return + runCatching { block() }.onFailure { cause -> + if (errorOccurred.compareAndSet(false, true)) { + XLog.w(getLog("runSafely", cause.message), cause) + isRendering.set(false) + handler.removeCallbacksAndMessages(null) + onError(cause) + } + } + } + + private fun renderFrame() { + runSafely { + if (!EGL14.eglMakeCurrent(eglDisplay, eglSurface, eglSurface, eglContext)) { + throw RuntimeException("eglMakeCurrent failed: error 0x${Integer.toHexString(EGL14.eglGetError())}") + } + surfaceTexture?.apply { + updateTexImage() + getTransformMatrix(stMatrix) + } + } + + if (!isRendering.get()) return + + val nowMs = MjpegMasterClock.relativeTimeMs().toDouble() + if (nextRenderTimeMs == 0.0) nextRenderTimeMs = nowMs + nextRenderTimeMs += frameIntervalMs + + val renderTime = measureTimeMillis { runSafely { drawFrame() } }.toDouble() + if (!isRendering.get()) return + + handler.removeCallbacks(renderFrameTask) + handler.postDelayed(renderFrameTask, (nextRenderTimeMs - nowMs + renderTime).coerceAtLeast(0.0).roundToLong()) + } + + private fun drawFrame() { + check(Thread.currentThread() == handlerThread.looper?.thread) { "All GL calls must be on Mp4EglRenderer HandlerThread." } + if (!isRendering.get()) return + + GLES20.glUseProgram(programId) + checkGlError("glUseProgram") + + GLES20.glUniformMatrix4fv(uMVPMatrixLoc, 1, false, MVP_MATRIX, 0) + GLES20.glUniformMatrix4fv(uSTMatrixLoc, 1, false, stMatrix, 0) + + GLES20.glEnableVertexAttribArray(aPositionHandle) + GLES20.glVertexAttribPointer(aPositionHandle, 2, GLES20.GL_FLOAT, false, 0, FULL_RECT_VERTICES_BUFFER) + + GLES20.glEnableVertexAttribArray(aTextureCoordHandle) + GLES20.glVertexAttribPointer(aTextureCoordHandle, 2, GLES20.GL_FLOAT, false, 0, FULL_RECT_TEX_BUFFER) + + GLES20.glActiveTexture(GLES20.GL_TEXTURE0) + GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textureId) + + GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4) + checkGlError("glDrawArrays") + + GLES20.glDisableVertexAttribArray(aPositionHandle) + GLES20.glDisableVertexAttribArray(aTextureCoordHandle) + GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, 0) + GLES20.glUseProgram(0) + + if (!EGL14.eglSwapBuffers(eglDisplay, eglSurface)) { + throw RuntimeException("eglSwapBuffers failed: error 0x${Integer.toHexString(EGL14.eglGetError())}") + } + } + + private fun eglSetup(encoderSurface: Surface) { + check(Thread.currentThread() == handlerThread.looper?.thread) { "All GL calls must be on Mp4EglRenderer HandlerThread." } + + eglDisplay = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY) + if (eglDisplay == EGL14.EGL_NO_DISPLAY) throw RuntimeException("Unable to get EGL14 display") + + val version = IntArray(2) + if (!EGL14.eglInitialize(eglDisplay, version, 0, version, 1)) throw RuntimeException("Unable to initialize EGL14") + + val attribList = intArrayOf( + EGL14.EGL_RED_SIZE, 8, + EGL14.EGL_GREEN_SIZE, 8, + EGL14.EGL_BLUE_SIZE, 8, + EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT, + EGL14.EGL_SURFACE_TYPE, EGL14.EGL_WINDOW_BIT, + RECORDABLE_ANDROID, 1, + EGL14.EGL_NONE + ) + + val configs = arrayOfNulls(1) + val numConfigs = IntArray(1) + EGL14.eglChooseConfig(eglDisplay, attribList, 0, configs, 0, 1, numConfigs, 0) + checkEglError("eglChooseConfig") + if (numConfigs[0] <= 0) throw RuntimeException("No EGL configs found") + + eglContext = EGL14.eglCreateContext( + eglDisplay, + configs[0], + EGL14.EGL_NO_CONTEXT, + intArrayOf(EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, EGL14.EGL_NONE), + 0 + ) + checkEglError("eglCreateContext") + + eglSurface = EGL14.eglCreateWindowSurface(eglDisplay, configs[0], encoderSurface, intArrayOf(EGL14.EGL_NONE), 0) + checkEglError("eglCreateWindowSurface") + if (eglSurface == EGL14.EGL_NO_SURFACE) throw RuntimeException("eglCreateWindowSurface returned EGL_NO_SURFACE.") + + if (!EGL14.eglMakeCurrent(eglDisplay, eglSurface, eglSurface, eglContext)) { + throw RuntimeException("eglMakeCurrent failed: error 0x${Integer.toHexString(EGL14.eglGetError())}") + } + } + + private fun createGLObjects() { + check(Thread.currentThread() == handlerThread.looper?.thread) { "All GL calls must be on Mp4EglRenderer HandlerThread." } + + val textures = IntArray(1) + GLES20.glGenTextures(1, textures, 0) + textureId = textures[0] + + GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textureId) + GLES20.glTexParameterf(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR.toFloat()) + GLES20.glTexParameterf(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR.toFloat()) + GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE) + GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE) + GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, 0) + + surfaceTexture = SurfaceTexture(textureId).apply { setDefaultBufferSize(width, height) } + Matrix.setIdentityM(stMatrix, 0) + programId = createGlProgram(VERTEX_SHADER, FRAGMENT_SHADER) + + aPositionHandle = GLES20.glGetAttribLocation(programId, "aPosition") + aTextureCoordHandle = GLES20.glGetAttribLocation(programId, "aTextureCoord") + uMVPMatrixLoc = GLES20.glGetUniformLocation(programId, "uMVPMatrix") + uSTMatrixLoc = GLES20.glGetUniformLocation(programId, "uSTMatrix") + + GLES20.glViewport(0, 0, width, height) + } + + private fun releaseGL(surfaceTextureToRelease: SurfaceTexture?) { + check(Thread.currentThread() == handlerThread.looper?.thread) { "All GL calls must be on Mp4EglRenderer HandlerThread." } + + if (eglDisplay == EGL14.EGL_NO_DISPLAY) return + + if (eglContext != EGL14.EGL_NO_CONTEXT) { + EGL14.eglMakeCurrent(eglDisplay, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_CONTEXT) + if (programId != 0) GLES20.glDeleteProgram(programId) + if (textureId != -1) GLES20.glDeleteTextures(1, intArrayOf(textureId), 0) + programId = 0 + textureId = -1 + } + + if (eglSurface != EGL14.EGL_NO_SURFACE) { + runCatching { EGL14.eglDestroySurface(eglDisplay, eglSurface) } + eglSurface = EGL14.EGL_NO_SURFACE + } + if (eglContext != EGL14.EGL_NO_CONTEXT) { + runCatching { EGL14.eglDestroyContext(eglDisplay, eglContext) } + eglContext = EGL14.EGL_NO_CONTEXT + } + EGL14.eglReleaseThread() + EGL14.eglTerminate(eglDisplay) + eglDisplay = EGL14.EGL_NO_DISPLAY + + surfaceTextureToRelease?.release() + surfaceTexture = null + } + + private fun checkEglError(msg: String) { + val error = EGL14.eglGetError() + if (error != EGL14.EGL_SUCCESS) throw RuntimeException("$msg. EGL error: 0x${Integer.toHexString(error)}") + } + + private fun checkGlError(op: String) { + val error = GLES20.glGetError() + if (error != GLES20.GL_NO_ERROR) throw RuntimeException("$op: glError $error") + } + + private fun createGlProgram(vsSource: String, fsSource: String): Int { + val vertexShader = compileShader(GLES20.GL_VERTEX_SHADER, vsSource) + val fragmentShader = compileShader(GLES20.GL_FRAGMENT_SHADER, fsSource) + + val program = GLES20.glCreateProgram() + checkGlError("glCreateProgram") + GLES20.glAttachShader(program, vertexShader) + GLES20.glAttachShader(program, fragmentShader) + GLES20.glLinkProgram(program) + + val linkStatus = IntArray(1) + GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, linkStatus, 0) + if (linkStatus[0] != GLES20.GL_TRUE) { + val infoLog = GLES20.glGetProgramInfoLog(program) + GLES20.glDeleteProgram(program) + throw RuntimeException("Could not link program: $infoLog") + } + + GLES20.glDetachShader(program, vertexShader) + GLES20.glDetachShader(program, fragmentShader) + GLES20.glDeleteShader(vertexShader) + GLES20.glDeleteShader(fragmentShader) + + return program + } + + private fun compileShader(type: Int, source: String): Int { + val shader = GLES20.glCreateShader(type) + GLES20.glShaderSource(shader, source) + GLES20.glCompileShader(shader) + + val compiled = IntArray(1) + GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compiled, 0) + if (compiled[0] == 0) { + val infoLog = GLES20.glGetShaderInfoLog(shader) + GLES20.glDeleteShader(shader) + throw RuntimeException("Could not compile shader $type: $infoLog") + } + return shader + } +} diff --git a/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/mp4/Mp4Models.kt b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/mp4/Mp4Models.kt new file mode 100644 index 00000000..10c80ab9 --- /dev/null +++ b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/mp4/Mp4Models.kt @@ -0,0 +1,93 @@ +package info.dvkr.screenstream.mjpeg.internal.mp4 + +internal data class Mp4VideoConfig( + val width: Int, + val height: Int, + val sps: ByteArray, + val pps: ByteArray, + val fps: Int +) { + val codecString: String = buildString { + append("avc1.") + val profileBytes = if (sps.size >= 4) byteArrayOf(sps[1], sps[2], sps[3]) else byteArrayOf(0x42, 0x00, 0x1F) + profileBytes.forEach { byte -> append((byte.toInt() and 0xFF).toString(16).padStart(2, '0')) } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Mp4VideoConfig + + if (width != other.width) return false + if (height != other.height) return false + if (!sps.contentEquals(other.sps)) return false + if (!pps.contentEquals(other.pps)) return false + if (fps != other.fps) return false + + return true + } + + override fun hashCode(): Int { + var result = width + result = 31 * result + height + result = 31 * result + sps.contentHashCode() + result = 31 * result + pps.contentHashCode() + result = 31 * result + fps + return result + } +} + +internal data class Mp4AudioConfig( + val sampleRate: Int, + val channelCount: Int, + val audioSpecificConfig: ByteArray +) { + val codecString: String = "mp4a.40.2" + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Mp4AudioConfig + + if (sampleRate != other.sampleRate) return false + if (channelCount != other.channelCount) return false + if (!audioSpecificConfig.contentEquals(other.audioSpecificConfig)) return false + + return true + } + + override fun hashCode(): Int { + var result = sampleRate + result = 31 * result + channelCount + result = 31 * result + audioSpecificConfig.contentHashCode() + return result + } +} + +internal data class Mp4StreamConfig( + val generation: Long, + val video: Mp4VideoConfig?, + val audio: Mp4AudioConfig? +) { + val mimeCodecString: String = buildList { + video?.let { add(it.codecString) } + audio?.let { add(it.codecString) } + }.joinToString(",") +} + +internal data class Mp4VideoPacket( + val generation: Long, + val data: ByteArray, + val timestampUs: Long, + val durationUs: Long, + val isKeyFrame: Boolean +) + +internal data class Mp4AudioPacket( + val generation: Long, + val data: ByteArray, + val timestampUs: Long, + val durationSamples: Int +) diff --git a/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/mp4/Mp4VideoEncoder.kt b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/mp4/Mp4VideoEncoder.kt new file mode 100644 index 00000000..fe5cc7c9 --- /dev/null +++ b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/mp4/Mp4VideoEncoder.kt @@ -0,0 +1,354 @@ +package info.dvkr.screenstream.mjpeg.internal.mp4 + +import android.graphics.SurfaceTexture +import android.media.MediaCodec +import android.media.MediaCodecInfo +import android.media.MediaFormat +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.HandlerThread +import android.os.Process +import com.elvishew.xlog.XLog +import info.dvkr.screenstream.common.getLog +import info.dvkr.screenstream.mjpeg.internal.audio.MjpegMasterClock +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer + +internal class Mp4VideoEncoder( + private val codecInfo: Mp4VideoEncoderInfo, + private val onVideoConfig: (sps: ByteArray, pps: ByteArray) -> Unit, + private val onVideoPacket: (Mp4VideoPacket) -> Unit, + private val onError: (Throwable) -> Unit +) { + private enum class State { IDLE, PREPARED, RUNNING, STOPPED } + + private data class StopSnapshot( + val eglRenderer: Mp4EglRenderer?, + val mediaCodec: MediaCodec?, + val handler: Handler?, + val handlerThread: HandlerThread? + ) + + private val encoderLock = Any() + private var currentState = State.IDLE + private var mediaCodec: MediaCodec? = null + private var handlerThread: HandlerThread? = null + private var handler: Handler? = null + private var eglRenderer: Mp4EglRenderer? = null + private var isCodecConfigSent: Boolean = false + private var lastBitrate: Int? = null + private var width: Int = 0 + private var height: Int = 0 + private var fps: Int = 30 + private var generation: Long = 0L + private val adjustedBufferInfo = MediaCodec.BufferInfo() + + internal val inputSurfaceTexture: SurfaceTexture? + get() = eglRenderer?.inputSurfaceTexture + + internal fun prepare(width: Int, height: Int, fps: Int, bitRate: Int, generation: Long) { + runCatching { + synchronized(encoderLock) { + require(width % 2 == 0 && height % 2 == 0) { "Width and height must be even. Received: $width x $height" } + require(fps > 0) { "FPS must be > 0. Received: $fps" } + check(currentState == State.IDLE) + + this.width = width + this.height = height + this.fps = fps + this.generation = generation + + val format = MediaFormat.createVideoFormat(H264_MIME_TYPE, width, height).apply { + setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface) + setInteger(MediaFormat.KEY_FRAME_RATE, fps) + setInteger(MediaFormat.KEY_BIT_RATE, bitRate) + setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1) + setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 0) + setInteger(MediaFormat.KEY_PRIORITY, 1) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + setInteger(MediaFormat.KEY_MAX_B_FRAMES, 0) + } + + if (codecInfo.isCBRModeSupported) { + setInteger(MediaFormat.KEY_BITRATE_MODE, MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_CBR) + } else { + setInteger(MediaFormat.KEY_BITRATE_MODE, MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_VBR) + } + } + + val encoder = MediaCodec.createByCodecName(codecInfo.name) + encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) + + eglRenderer = Mp4EglRenderer(width, height, encoder.createInputSurface(), onError).apply { setFps(fps) } + + handlerThread = HandlerThread("Mp4VideoEncoderHandler", Process.THREAD_PRIORITY_DISPLAY).apply { start() } + handler = Handler(handlerThread!!.looper) + encoder.setCallback(createCodecCallback(), handler) + + mediaCodec = encoder + lastBitrate = bitRate + currentState = State.PREPARED + } + }.onFailure { cause -> + stopInternal(force = true, logTag = "prepareCleanup") + onError(cause) + } + } + + internal fun start(): Unit = synchronized(encoderLock) { + if (currentState != State.PREPARED) return + isCodecConfigSent = false + eglRenderer?.startAsync() + mediaCodec?.start() + currentState = State.RUNNING + } + + internal fun stop() { + stopInternal(force = false, logTag = "stopInternal") + } + + internal fun setBitrate(newBitrate: Int): Unit = synchronized(encoderLock) { + if (currentState != State.RUNNING || newBitrate <= 0) return + val bitrateRange = codecInfo.capabilities.videoCapabilities?.bitrateRange + val bitrate = bitrateRange?.let { newBitrate.coerceIn(it.lower, it.upper) } ?: newBitrate + if (lastBitrate == bitrate) return + runCatching { + mediaCodec?.setParameters(Bundle().apply { putInt(MediaCodec.PARAMETER_KEY_VIDEO_BITRATE, bitrate) }) + }.onSuccess { + lastBitrate = bitrate + }.onFailure { + XLog.w(getLog("setBitrate", "Error while updating bitrate."), it) + } + } + + private fun stopInternal(force: Boolean, logTag: String): Boolean { + val snapshot = synchronized(encoderLock) { + if (!force && (currentState == State.IDLE || currentState == State.STOPPED)) return@synchronized null + + currentState = State.STOPPED + isCodecConfigSent = false + lastBitrate = null + + StopSnapshot(eglRenderer, mediaCodec, handler, handlerThread).also { + eglRenderer = null + mediaCodec = null + handler = null + handlerThread = null + } + } ?: return false + + snapshot.eglRenderer?.stop() + + snapshot.mediaCodec?.runCatching { + stop() + release() + }?.onFailure { + XLog.w(getLog(logTag, "mediaCodec.stop() exception: ${it.message}"), it) + } + + snapshot.handler?.removeCallbacksAndMessages(null) + snapshot.handlerThread?.apply { + quitSafely() + runCatching { join(250) }.onFailure { XLog.w(getLog(logTag, "handlerThread.join() interrupted"), it) } + if (isAlive) { + quit() + runCatching { join(250) } + } + } + + synchronized(encoderLock) { + currentState = State.IDLE + } + return true + } + + private fun isCallbackCodecActive(codec: MediaCodec): Boolean = synchronized(encoderLock) { + codec === mediaCodec && currentState == State.RUNNING + } + + private fun createCodecCallback(): MediaCodec.Callback = object : MediaCodec.Callback() { + override fun onInputBufferAvailable(codec: MediaCodec, index: Int) = Unit + + override fun onOutputBufferAvailable(codec: MediaCodec, index: Int, info: MediaCodec.BufferInfo) { + runCatching { + val packet = synchronized(encoderLock) { + val activeCodec = mediaCodec + if (codec !== activeCodec || currentState != State.RUNNING || info.size == 0) { + releaseOutputBufferSafely(codec, index) + return + } + + val outputBuffer = codec.getOutputBuffer(index) ?: run { + releaseOutputBufferSafely(codec, index) + return + } + + outputBuffer.position(info.offset) + outputBuffer.limit(info.offset + info.size) + + val adjustedInfo = adjustedBufferInfo.apply { + val forcedPtsUs = MjpegMasterClock.relativeTimeUs() + set(info.offset, info.size, forcedPtsUs, info.flags) + } + + val flags = adjustedInfo.flags + val isKeyFrame = flags.hasFlag(MediaCodec.BUFFER_FLAG_KEY_FRAME) + if (flags.hasFlag(MediaCodec.BUFFER_FLAG_CODEC_CONFIG)) { + if (!isCodecConfigSent) { + outputBuffer.duplicate().extractSpsPps()?.let { (sps, pps) -> + onVideoConfig(sps.stripAnnexBStartCode(), pps.stripAnnexBStartCode()) + isCodecConfigSent = true + } + } + releaseOutputBufferSafely(codec, index) + return + } + + if (flags.hasFlag(MediaCodec.BUFFER_FLAG_END_OF_STREAM)) { + releaseOutputBufferSafely(codec, index) + return + } + + if (!isCodecConfigSent && isKeyFrame) { + outputBuffer.duplicate().extractSpsPps()?.let { (sps, pps) -> + onVideoConfig(sps.stripAnnexBStartCode(), pps.stripAnnexBStartCode()) + isCodecConfigSent = true + } + } + + val data = outputBuffer.duplicate().toLengthPrefixedNalBytes(adjustedInfo.offset, adjustedInfo.size) + releaseOutputBufferSafely(codec, index) + Mp4VideoPacket( + generation = generation, + data = data, + timestampUs = adjustedInfo.presentationTimeUs, + durationUs = 1_000_000L / fps.coerceAtLeast(1), + isKeyFrame = isKeyFrame + ) + } + onVideoPacket(packet) + }.onFailure { cause -> + releaseOutputBufferSafely(codec, index) + if (!isCallbackCodecActive(codec)) return@onFailure + XLog.w(getLog("CodecCallback.onOutputBufferAvailable", "onFailure: ${cause.message}"), cause) + onError(cause) + } + } + + override fun onError(codec: MediaCodec, cause: MediaCodec.CodecException) { + if (!isCallbackCodecActive(codec)) return + XLog.w(getLog("CodecCallback.onError", "onFailure: ${cause.message}"), cause) + onError(cause) + } + + override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat): Unit = synchronized(encoderLock) { + if (isCodecConfigSent) return + val spsBuffer = format.getByteBuffer("csd-0") + val ppsBuffer = format.getByteBuffer("csd-1") + val sps = spsBuffer?.toRawNalOrAnnexBFirstNal() + val pps = ppsBuffer?.toRawNalOrAnnexBFirstNal() + if (sps != null && pps != null) { + onVideoConfig(sps.stripAnnexBStartCode(), pps.stripAnnexBStartCode()) + isCodecConfigSent = true + } + } + } + + private fun Int.hasFlag(flag: Int): Boolean = (this and flag) != 0 + + private fun releaseOutputBufferSafely(codec: MediaCodec, index: Int) { + runCatching { codec.releaseOutputBuffer(index, false) } + } + + private fun ByteBuffer.extractSpsPps(): Pair? { + val bytes = ByteArray(remaining()).also { + mark() + get(it) + reset() + } + val nals = bytes.splitAnnexBNals().ifEmpty { bytes.splitLengthPrefixedNals() } + val sps = nals.firstOrNull { it.isNotEmpty() && (it[0].toInt() and 0x1F) == 7 } ?: return null + val pps = nals.firstOrNull { it.isNotEmpty() && (it[0].toInt() and 0x1F) == 8 } ?: return null + return sps to pps + } + + private fun ByteBuffer.toRawNalOrAnnexBFirstNal(): ByteArray? { + val bytes = ByteArray(remaining()).also { + mark() + get(it) + reset() + } + return bytes.splitAnnexBNals().firstOrNull() + ?: bytes.splitLengthPrefixedNals().firstOrNull() + ?: bytes.takeIf { it.isNotEmpty() } + } + + private fun ByteBuffer.toLengthPrefixedNalBytes(offset: Int, size: Int): ByteArray { + val duplicate = duplicate().apply { + position(offset) + limit(offset + size) + } + val bytes = ByteArray(size).also { duplicate.get(it) } + val annexBNals = bytes.splitAnnexBNals() + val nals = annexBNals.ifEmpty { bytes.splitLengthPrefixedNals() }.ifEmpty { listOf(bytes) } + return ByteArrayOutputStream().also { output -> + nals.filter { it.isNotEmpty() }.forEach { nal -> + output.write(byteArrayOf((nal.size ushr 24).toByte(), (nal.size ushr 16).toByte(), (nal.size ushr 8).toByte(), nal.size.toByte())) + output.write(nal) + } + }.toByteArray() + } + + private fun ByteArray.splitAnnexBNals(): List { + fun startCodeLength(index: Int): Int = when { + index + 3 < size && this[index] == 0.toByte() && this[index + 1] == 0.toByte() && + this[index + 2] == 0.toByte() && this[index + 3] == 1.toByte() -> 4 + index + 2 < size && this[index] == 0.toByte() && this[index + 1] == 0.toByte() && this[index + 2] == 1.toByte() -> 3 + else -> 0 + } + + val result = mutableListOf() + var i = 0 + while (i < size) { + val startCode = startCodeLength(i) + if (startCode == 0) { + i++ + continue + } + val start = i + startCode + var end = start + while (end < size && startCodeLength(end) == 0) end++ + if (end > start) result.add(copyOfRange(start, end)) + i = end + } + return result + } + + private fun ByteArray.splitLengthPrefixedNals(): List { + fun parse(lengthBytes: Int): List? { + val result = mutableListOf() + var offset = 0 + while (offset + lengthBytes <= size) { + var length = 0 + repeat(lengthBytes) { index -> length = (length shl 8) or (this[offset + index].toInt() and 0xFF) } + offset += lengthBytes + if (length <= 0 || offset + length > size) return null + result.add(copyOfRange(offset, offset + length)) + offset += length + } + return if (offset == size && result.isNotEmpty()) result else null + } + return parse(4) ?: parse(2) ?: emptyList() + } + + private fun ByteArray.stripAnnexBStartCode(): ByteArray { + val startCodeLength = when { + size >= 4 && this[0] == 0.toByte() && this[1] == 0.toByte() && this[2] == 0.toByte() && this[3] == 1.toByte() -> 4 + size >= 3 && this[0] == 0.toByte() && this[1] == 0.toByte() && this[2] == 1.toByte() -> 3 + else -> 0 + } + return if (startCodeLength > 0) copyOfRange(startCodeLength, size) else this + } +} diff --git a/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/mp4/Mp4VideoEncoderUtils.kt b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/mp4/Mp4VideoEncoderUtils.kt new file mode 100644 index 00000000..32defa2a --- /dev/null +++ b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/internal/mp4/Mp4VideoEncoderUtils.kt @@ -0,0 +1,189 @@ +package info.dvkr.screenstream.mjpeg.internal.mp4 + +import android.media.MediaCodecInfo +import android.media.MediaCodecInfo.EncoderCapabilities +import android.media.MediaCodecList +import android.os.Build +import android.util.Range +import kotlin.math.abs +import kotlin.math.ceil +import kotlin.math.floor +import kotlin.math.roundToInt + +internal const val H264_MIME_TYPE: String = "video/avc" + +internal data class Mp4VideoEncoderInfo( + val name: String, + val vendorName: String, + val isHardwareAccelerated: Boolean, + val isCBRModeSupported: Boolean, + val capabilities: MediaCodecInfo.CodecCapabilities +) + +internal object Mp4VideoEncoderUtils { + private val allAvailableEncoders: List by lazy { + MediaCodecList(MediaCodecList.ALL_CODECS).codecInfos.filter { it.isEncoder } + } + + val selectedH264Encoder: Mp4VideoEncoderInfo? + get() = availableH264Encoders.firstOrNull() + + val availableH264Encoders: List by lazy { + allAvailableEncoders + .filter { encoder -> encoder.supportedTypes.any { it.equals(H264_MIME_TYPE, ignoreCase = true) } } + .filter { encoder -> + runCatching { + encoder.getCapabilitiesForType(H264_MIME_TYPE).colorFormats.contains( + MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface + ) + }.getOrDefault(false) + } + .map { encoder -> + Mp4VideoEncoderInfo( + name = encoder.name, + vendorName = encoder.name.asVendorName(), + isHardwareAccelerated = encoder.isHardwareAcceleratedCompat(), + isCBRModeSupported = encoder.isCbrCapable(H264_MIME_TYPE), + capabilities = encoder.getCapabilitiesForType(H264_MIME_TYPE) + ) + } + .sortedWith( + compareBy( + { + when { + it.isHardwareAccelerated && it.isCBRModeSupported -> 0 + it.isHardwareAccelerated && !it.isCBRModeSupported -> 1 + !it.isHardwareAccelerated && it.isCBRModeSupported -> 2 + else -> 3 + } + }, + { getCodecScore(it.name) } + ) + ) + } + + fun selectH264Encoder(autoSelect: Boolean, preferredName: String): Mp4VideoEncoderInfo? = + if (autoSelect) selectedH264Encoder + else availableH264Encoders.firstOrNull { it.name == preferredName } ?: selectedH264Encoder + + private fun MediaCodecInfo.isCbrCapable(mimeType: String): Boolean = + runCatching { + getCapabilitiesForType(mimeType) + ?.encoderCapabilities + ?.isBitrateModeSupported(EncoderCapabilities.BITRATE_MODE_CBR) ?: false + }.getOrDefault(false) + + private fun MediaCodecInfo.isHardwareAcceleratedCompat(): Boolean = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + isHardwareAccelerated + } else { + !isSoftwareOnlyCompat() + } + + private fun MediaCodecInfo.isSoftwareOnlyCompat(): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) return !isHardwareAccelerated + + val lowerName = name.lowercase() + return when { + lowerName.startsWith("arc.") -> false + lowerName.startsWith("omx.google.") -> true + lowerName.startsWith("omx.ffmpeg.") -> true + lowerName.startsWith("c2.android.") -> true + lowerName.startsWith("c2.google.") -> true + lowerName.startsWith("omx.sec.") && lowerName.contains(".sw.") -> true + lowerName == "omx.qcom.video.decoder.hevcswvdec" -> true + !lowerName.startsWith("omx.") && !lowerName.startsWith("c2.") -> true + else -> false + } + } + + private fun getCodecScore(name: String): Int = when (name.lowercase()) { + "omx.google.h264.encoder" -> 1 + "c2.android.avc.encoder" -> 1 + else -> 0 + } + + private fun String.asVendorName(): String = when { + contains("qcom", ignoreCase = true) -> "Qualcomm" + contains("exynos", ignoreCase = true) -> "Exynos" + contains("mtk", ignoreCase = true) || contains("mediatek", ignoreCase = true) -> "MediaTek" + contains("nvidia", ignoreCase = true) -> "NVIDIA" + contains("intel", ignoreCase = true) -> "Intel" + contains("google", ignoreCase = true) -> "Google" + contains("sprd", ignoreCase = true) -> "Spreadtrum" + else -> "Generic" + } + + internal fun MediaCodecInfo.VideoCapabilities.adjustSize( + sourceWidth: Int, + sourceHeight: Int, + resizeFactor: Float, + exactWidth: Int, + exactHeight: Int, + stretch: Boolean + ): Pair { + val initial = if (exactWidth > 0 && exactHeight > 0) { + exactWidth to exactHeight + } else { + (sourceWidth * resizeFactor).roundToInt() to (sourceHeight * resizeFactor).roundToInt() + } + + var targetWidth = initial.first.coerceIn(supportedWidths.lower, supportedWidths.upper) + var targetHeight = initial.second.coerceIn(supportedHeights.lower, supportedHeights.upper) + + if (!stretch) { + val aspectRatio = sourceHeight.toDouble() / sourceWidth.toDouble() + val heightFromWidth = (targetWidth * aspectRatio).roundToInt().coerceIn(supportedHeights.lower, supportedHeights.upper) + val widthFromHeight = (targetHeight / aspectRatio).roundToInt().coerceIn(supportedWidths.lower, supportedWidths.upper) + if (abs(heightFromWidth - targetHeight) < abs(widthFromHeight - targetWidth)) { + targetHeight = heightFromWidth + } else { + targetWidth = widthFromHeight + } + } + + targetWidth = alignToMultiple(targetWidth, widthAlignment, supportedWidths) + targetHeight = alignToMultiple(targetHeight, heightAlignment, supportedHeights) + + if (isSizeSupported(targetWidth, targetHeight)) return targetWidth to targetHeight + + val aspectRatio = sourceHeight.toDouble() / sourceWidth.toDouble() + var bestWidth = targetWidth + var bestHeight = targetHeight + var bestError = Double.MAX_VALUE + + for (width in supportedWidths.lower..supportedWidths.upper step widthAlignment.coerceAtLeast(1)) { + val rawHeight = (width * aspectRatio).roundToInt() + val height = alignToMultiple(rawHeight, heightAlignment, supportedHeights) + if (isSizeSupported(width, height)) { + val error = abs(width - targetWidth) + abs(height - targetHeight) + if (error < bestError) { + bestError = error.toDouble() + bestWidth = width + bestHeight = height + } + } + } + + return bestWidth to bestHeight + } + + internal fun MediaCodecInfo.VideoCapabilities.getBitRateInKbits(): ClosedRange { + val supported = bitrateRange ?: Range(1_000, 240_000_000) + val min = floor(supported.lower / 1000f).toInt().coerceAtLeast(1) + val max = ceil(supported.upper / 1000f).toInt().coerceIn(1, 240_000) + return min..max + } + + private fun alignToMultiple(value: Int, alignment: Int, range: Range): Int { + val aligned = if (alignment <= 1) value else { + val remainder = value % alignment + if (remainder == 0) value else { + val down = value - remainder + val up = down + alignment + if (abs(value - up) < abs(value - down)) up else down + } + } + return aligned.coerceIn(range.lower, range.upper) + } +} diff --git a/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/settings/MjpegSettings.kt b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/settings/MjpegSettings.kt index 8378c0de..c7f67c45 100644 --- a/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/settings/MjpegSettings.kt +++ b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/settings/MjpegSettings.kt @@ -4,6 +4,7 @@ import androidx.annotation.IntDef import androidx.compose.runtime.Immutable import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.floatPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import kotlinx.coroutines.flow.StateFlow @@ -21,6 +22,22 @@ public interface MjpegSettings { public val HTML_KEEP_IMAGE_ON_RECONNECT: Preferences.Key = booleanPreferencesKey("HTML_KEEP_IMAGE_ON_RECONNECT") public val HTML_BACK_COLOR: Preferences.Key = intPreferencesKey("HTML_BACK_COLOR") public val HTML_FIT_WINDOW: Preferences.Key = booleanPreferencesKey("HTML_FIT_WINDOW") + public val STREAM_FORMAT: Preferences.Key = intPreferencesKey("STREAM_FORMAT") + public val STREAM_AUDIO_ONLY: Preferences.Key = booleanPreferencesKey("STREAM_AUDIO_ONLY") + + public val VIDEO_CODEC_AUTO_SELECT: Preferences.Key = booleanPreferencesKey("VIDEO_CODEC_AUTO_SELECT") + public val VIDEO_CODEC: Preferences.Key = stringPreferencesKey("VIDEO_CODEC") + public val VIDEO_BITRATE: Preferences.Key = intPreferencesKey("VIDEO_BITRATE") + + public val AUDIO_BITRATE: Preferences.Key = intPreferencesKey("AUDIO_BITRATE") + public val ENABLE_MIC: Preferences.Key = booleanPreferencesKey("ENABLE_MIC") + public val MUTE_MIC: Preferences.Key = booleanPreferencesKey("MUTE_MIC") + public val VOLUME_MIC: Preferences.Key = floatPreferencesKey("VOLUME_MIC") + public val ENABLE_DEVICE_AUDIO: Preferences.Key = booleanPreferencesKey("ENABLE_DEVICE_AUDIO") + public val MUTE_DEVICE_AUDIO: Preferences.Key = booleanPreferencesKey("MUTE_DEVICE_AUDIO") + public val VOLUME_DEVICE_AUDIO: Preferences.Key = floatPreferencesKey("VOLUME_DEVICE_AUDIO") + public val AUDIO_ECHO_CANCELLER: Preferences.Key = booleanPreferencesKey("AUDIO_ECHO_CANCELLER") + public val AUDIO_NOISE_SUPPRESSOR: Preferences.Key = booleanPreferencesKey("AUDIO_NOISE_SUPPRESSOR") public val VR_MODE: Preferences.Key = intPreferencesKey("VR_MODE") public val IMAGE_CROP: Preferences.Key = booleanPreferencesKey("IMAGE_CROP") @@ -63,6 +80,22 @@ public interface MjpegSettings { public const val HTML_KEEP_IMAGE_ON_RECONNECT: Boolean = true public const val HTML_BACK_COLOR: Int = -15723496// "FF101418".toLong(radix = 16).toInt() public const val HTML_FIT_WINDOW: Boolean = true + public const val STREAM_FORMAT: Int = Values.STREAM_FORMAT_MJPEG + public const val STREAM_AUDIO_ONLY: Boolean = false + + public const val VIDEO_CODEC_AUTO_SELECT: Boolean = true + public const val VIDEO_CODEC: String = "" + public const val VIDEO_BITRATE: Int = 4500 * 1000 + + public const val AUDIO_BITRATE: Int = 128 * 1000 + public const val ENABLE_MIC: Boolean = false + public const val MUTE_MIC: Boolean = false + public const val VOLUME_MIC: Float = 1.0F + public const val ENABLE_DEVICE_AUDIO: Boolean = false + public const val MUTE_DEVICE_AUDIO: Boolean = false + public const val VOLUME_DEVICE_AUDIO: Float = 1.0F + public const val AUDIO_ECHO_CANCELLER: Boolean = true + public const val AUDIO_NOISE_SUPPRESSOR: Boolean = false public const val VR_MODE_DISABLE: Int = 0 public const val VR_MODE_LEFT: Int = 1 @@ -107,6 +140,9 @@ public interface MjpegSettings { public const val FLIP_HORIZONTAL: Int = 1 public const val FLIP_VERTICAL: Int = 2 + public const val STREAM_FORMAT_MJPEG: Int = 0 + public const val STREAM_FORMAT_MP4: Int = 1 + @IntDef(flag = true, value = [INTERFACE_WIFI, INTERFACE_MOBILE, INTERFACE_ETHERNET, INTERFACE_VPN]) @Retention(AnnotationRetention.SOURCE) @@ -139,6 +175,22 @@ public interface MjpegSettings { public val htmlKeepImageOnReconnect: Boolean = Default.HTML_KEEP_IMAGE_ON_RECONNECT, public val htmlBackColor: Int = Default.HTML_BACK_COLOR, public val htmlFitWindow: Boolean = Default.HTML_FIT_WINDOW, + public val streamFormat: Int = Default.STREAM_FORMAT, + public val streamAudioOnly: Boolean = Default.STREAM_AUDIO_ONLY, + + public val videoCodecAutoSelect: Boolean = Default.VIDEO_CODEC_AUTO_SELECT, + public val videoCodec: String = Default.VIDEO_CODEC, + public val videoBitrateBits: Int = Default.VIDEO_BITRATE, + + public val audioBitrateBits: Int = Default.AUDIO_BITRATE, + public val enableMic: Boolean = Default.ENABLE_MIC, + public val muteMic: Boolean = Default.MUTE_MIC, + public val volumeMic: Float = Default.VOLUME_MIC, + public val enableDeviceAudio: Boolean = Default.ENABLE_DEVICE_AUDIO, + public val muteDeviceAudio: Boolean = Default.MUTE_DEVICE_AUDIO, + public val volumeDeviceAudio: Float = Default.VOLUME_DEVICE_AUDIO, + public val audioEchoCanceller: Boolean = Default.AUDIO_ECHO_CANCELLER, + public val audioNoiseSuppressor: Boolean = Default.AUDIO_NOISE_SUPPRESSOR, public val vrMode: Int = Default.VR_MODE_DISABLE, public val imageCrop: Boolean = Default.IMAGE_CROP, diff --git a/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/settings/MjpegSettingsImpl.kt b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/settings/MjpegSettingsImpl.kt index 76125a9c..0fb0bfa8 100644 --- a/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/settings/MjpegSettingsImpl.kt +++ b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/settings/MjpegSettingsImpl.kt @@ -75,6 +75,48 @@ internal class MjpegSettingsImpl(context: Context) : MjpegSettings { if (newSettings.htmlFitWindow != MjpegSettings.Default.HTML_FIT_WINDOW) set(MjpegSettings.Key.HTML_FIT_WINDOW, newSettings.htmlFitWindow) + if (newSettings.streamFormat != MjpegSettings.Default.STREAM_FORMAT) + set(MjpegSettings.Key.STREAM_FORMAT, newSettings.streamFormat) + + if (newSettings.streamAudioOnly != MjpegSettings.Default.STREAM_AUDIO_ONLY) + set(MjpegSettings.Key.STREAM_AUDIO_ONLY, newSettings.streamAudioOnly) + + if (newSettings.videoCodecAutoSelect != MjpegSettings.Default.VIDEO_CODEC_AUTO_SELECT) + set(MjpegSettings.Key.VIDEO_CODEC_AUTO_SELECT, newSettings.videoCodecAutoSelect) + + if (newSettings.videoCodec != MjpegSettings.Default.VIDEO_CODEC) + set(MjpegSettings.Key.VIDEO_CODEC, newSettings.videoCodec) + + if (newSettings.videoBitrateBits != MjpegSettings.Default.VIDEO_BITRATE) + set(MjpegSettings.Key.VIDEO_BITRATE, newSettings.videoBitrateBits) + + if (newSettings.audioBitrateBits != MjpegSettings.Default.AUDIO_BITRATE) + set(MjpegSettings.Key.AUDIO_BITRATE, newSettings.audioBitrateBits) + + if (newSettings.enableMic != MjpegSettings.Default.ENABLE_MIC) + set(MjpegSettings.Key.ENABLE_MIC, newSettings.enableMic) + + if (newSettings.muteMic != MjpegSettings.Default.MUTE_MIC) + set(MjpegSettings.Key.MUTE_MIC, newSettings.muteMic) + + if (newSettings.volumeMic != MjpegSettings.Default.VOLUME_MIC) + set(MjpegSettings.Key.VOLUME_MIC, newSettings.volumeMic) + + if (newSettings.enableDeviceAudio != MjpegSettings.Default.ENABLE_DEVICE_AUDIO) + set(MjpegSettings.Key.ENABLE_DEVICE_AUDIO, newSettings.enableDeviceAudio) + + if (newSettings.muteDeviceAudio != MjpegSettings.Default.MUTE_DEVICE_AUDIO) + set(MjpegSettings.Key.MUTE_DEVICE_AUDIO, newSettings.muteDeviceAudio) + + if (newSettings.volumeDeviceAudio != MjpegSettings.Default.VOLUME_DEVICE_AUDIO) + set(MjpegSettings.Key.VOLUME_DEVICE_AUDIO, newSettings.volumeDeviceAudio) + + if (newSettings.audioEchoCanceller != MjpegSettings.Default.AUDIO_ECHO_CANCELLER) + set(MjpegSettings.Key.AUDIO_ECHO_CANCELLER, newSettings.audioEchoCanceller) + + if (newSettings.audioNoiseSuppressor != MjpegSettings.Default.AUDIO_NOISE_SUPPRESSOR) + set(MjpegSettings.Key.AUDIO_NOISE_SUPPRESSOR, newSettings.audioNoiseSuppressor) + if (newSettings.vrMode != MjpegSettings.Default.VR_MODE_DISABLE) set(MjpegSettings.Key.VR_MODE, newSettings.vrMode) @@ -170,6 +212,22 @@ internal class MjpegSettingsImpl(context: Context) : MjpegSettings { htmlKeepImageOnReconnect = this[MjpegSettings.Key.HTML_KEEP_IMAGE_ON_RECONNECT] ?: MjpegSettings.Default.HTML_KEEP_IMAGE_ON_RECONNECT, htmlBackColor = this[MjpegSettings.Key.HTML_BACK_COLOR] ?: MjpegSettings.Default.HTML_BACK_COLOR, htmlFitWindow = this[MjpegSettings.Key.HTML_FIT_WINDOW] ?: MjpegSettings.Default.HTML_FIT_WINDOW, + streamFormat = this[MjpegSettings.Key.STREAM_FORMAT] ?: MjpegSettings.Default.STREAM_FORMAT, + streamAudioOnly = this[MjpegSettings.Key.STREAM_AUDIO_ONLY] ?: MjpegSettings.Default.STREAM_AUDIO_ONLY, + + videoCodecAutoSelect = this[MjpegSettings.Key.VIDEO_CODEC_AUTO_SELECT] ?: MjpegSettings.Default.VIDEO_CODEC_AUTO_SELECT, + videoCodec = this[MjpegSettings.Key.VIDEO_CODEC] ?: MjpegSettings.Default.VIDEO_CODEC, + videoBitrateBits = this[MjpegSettings.Key.VIDEO_BITRATE] ?: MjpegSettings.Default.VIDEO_BITRATE, + + audioBitrateBits = this[MjpegSettings.Key.AUDIO_BITRATE] ?: MjpegSettings.Default.AUDIO_BITRATE, + enableMic = this[MjpegSettings.Key.ENABLE_MIC] ?: MjpegSettings.Default.ENABLE_MIC, + muteMic = this[MjpegSettings.Key.MUTE_MIC] ?: MjpegSettings.Default.MUTE_MIC, + volumeMic = this[MjpegSettings.Key.VOLUME_MIC] ?: MjpegSettings.Default.VOLUME_MIC, + enableDeviceAudio = this[MjpegSettings.Key.ENABLE_DEVICE_AUDIO] ?: MjpegSettings.Default.ENABLE_DEVICE_AUDIO, + muteDeviceAudio = this[MjpegSettings.Key.MUTE_DEVICE_AUDIO] ?: MjpegSettings.Default.MUTE_DEVICE_AUDIO, + volumeDeviceAudio = this[MjpegSettings.Key.VOLUME_DEVICE_AUDIO] ?: MjpegSettings.Default.VOLUME_DEVICE_AUDIO, + audioEchoCanceller = this[MjpegSettings.Key.AUDIO_ECHO_CANCELLER] ?: MjpegSettings.Default.AUDIO_ECHO_CANCELLER, + audioNoiseSuppressor = this[MjpegSettings.Key.AUDIO_NOISE_SUPPRESSOR] ?: MjpegSettings.Default.AUDIO_NOISE_SUPPRESSOR, vrMode = this[MjpegSettings.Key.VR_MODE] ?: MjpegSettings.Default.VR_MODE_DISABLE, imageCrop = this[MjpegSettings.Key.IMAGE_CROP] ?: MjpegSettings.Default.IMAGE_CROP, diff --git a/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/ui/MjpegMainScreenUI.kt b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/ui/MjpegMainScreenUI.kt index 17323e7a..c905b592 100644 --- a/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/ui/MjpegMainScreenUI.kt +++ b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/ui/MjpegMainScreenUI.kt @@ -1,5 +1,6 @@ package info.dvkr.screenstream.mjpeg.ui +import android.os.Build import androidx.compose.animation.Crossfade import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.PaddingValues @@ -27,6 +28,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.dropUnlessStarted +import info.dvkr.screenstream.common.analytics.EntryPoint import info.dvkr.screenstream.common.module.StreamingModule import info.dvkr.screenstream.common.notification.NotificationHelper import info.dvkr.screenstream.common.ui.DoubleClickProtection @@ -38,6 +40,7 @@ import info.dvkr.screenstream.mjpeg.internal.MjpegEvent import info.dvkr.screenstream.mjpeg.internal.MjpegStreamingService import info.dvkr.screenstream.mjpeg.settings.MjpegSettings import info.dvkr.screenstream.mjpeg.ui.main.cards.AdvancedSettingsCard +import info.dvkr.screenstream.mjpeg.ui.main.cards.AudioSettingsCard import info.dvkr.screenstream.mjpeg.ui.main.cards.ClientsCard import info.dvkr.screenstream.mjpeg.ui.main.cards.ErrorCard import info.dvkr.screenstream.mjpeg.ui.main.cards.GeneralSettingsCard @@ -67,6 +70,7 @@ internal fun MjpegMainScreenUI( val context = LocalContext.current val state = mjpegState.value val settings = mjpegSettingsState.value + val mp4Selected = settings.streamFormat == MjpegSettings.Values.STREAM_FORMAT_MP4 val updateSettings: (MjpegSettings.Data.() -> MjpegSettings.Data) -> Unit = { transform -> scope.launch { mjpegSettings.updateData(transform) } } @@ -137,12 +141,25 @@ internal fun MjpegMainScreenUI( item(key = "SETTINGS_IMAGE") { ImageSettingsCard( settings = settings, + mp4Selected = mp4Selected, + isStreaming = state.isStreaming, updateSettings = updateSettings, windowWidthSizeClass = windowWidthSizeClass, modifier = Modifier.padding(8.dp) ) } + if (mp4Selected) { + item(key = "SETTINGS_AUDIO") { + AudioSettingsCard( + isStreaming = state.isStreaming, + settings = settings, + updateSettings = updateSettings, + modifier = Modifier.padding(8.dp) + ) + } + } + item(key = "SETTINGS_SECURITY") { SecuritySettingsCard( settings = settings, @@ -168,12 +185,16 @@ internal fun MjpegMainScreenUI( } val doubleClickProtection = remember { DoubleClickProtection.get() } + val deviceAudioSelected = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && settings.enableDeviceAudio + val startWithoutScreenCapture = mp4Selected && settings.streamAudioOnly && settings.enableMic && deviceAudioSelected.not() Button( onClick = dropUnlessStarted { doubleClickProtection.processClick { if (state.isStreaming) { sendEvent(MjpegEvent.Intentable.StopStream("User action: Button")) + } else if (startWithoutScreenCapture) { + sendEvent(MjpegStreamingService.InternalEvent.StartMicrophoneAudioOnlyStream(EntryPoint.BUTTON)) } else { screenCaptureStartRequester.request() } diff --git a/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/ui/main/cards/AudioSettingsCard.kt b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/ui/main/cards/AudioSettingsCard.kt new file mode 100644 index 00000000..685d861a --- /dev/null +++ b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/ui/main/cards/AudioSettingsCard.kt @@ -0,0 +1,413 @@ +package info.dvkr.screenstream.mjpeg.ui.main.cards + +import android.Manifest +import android.content.Intent +import android.os.Build +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.DrawableRes +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.toggleable +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import androidx.core.net.toUri +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.elvishew.xlog.XLog +import info.dvkr.screenstream.common.findActivity +import info.dvkr.screenstream.common.getLog +import info.dvkr.screenstream.common.isPermissionGranted +import info.dvkr.screenstream.common.shouldShowPermissionRationale +import info.dvkr.screenstream.common.ui.ExpandableCard +import info.dvkr.screenstream.common.ui.conditional +import info.dvkr.screenstream.mjpeg.R +import info.dvkr.screenstream.mjpeg.internal.audio.MjpegAudioEncoderUtils +import info.dvkr.screenstream.mjpeg.internal.audio.MjpegAudioEncoderUtils.getBitRateInKbits +import info.dvkr.screenstream.mjpeg.settings.MjpegSettings +import kotlin.math.roundToInt + +@Composable +internal fun AudioSettingsCard( + isStreaming: Boolean, + settings: MjpegSettings.Data, + updateSettings: (MjpegSettings.Data.() -> MjpegSettings.Data) -> Unit, + modifier: Modifier = Modifier, +) { + var showRecordAudioPermission by rememberSaveable { mutableStateOf(false) } + var pendingPermissionTarget by rememberSaveable { mutableStateOf(null) } + val expanded = rememberSaveable { mutableStateOf(false) } + + ExpandableCard( + expanded = expanded.value, + onExpandedChange = { expanded.value = it }, + headerContent = { + Column( + modifier = Modifier + .align(Alignment.CenterStart) + .padding(start = 48.dp) + ) { + Text( + text = stringResource(R.string.mjpeg_audio_parameters), + style = MaterialTheme.typography.titleMedium + ) + } + }, + modifier = modifier + ) { + val context = LocalContext.current + + AudioSourceRow( + text = stringResource(R.string.mjpeg_audio_mic), + mainIconId = R.drawable.mic_24px, + muteIconId = R.drawable.mic_off_24px, + muteIconContentDescription = stringResource(R.string.mjpeg_audio_mic_mute), + isStreaming = isStreaming, + active = settings.enableMic, + onActiveChange = { active -> + if (active.not() || context.isPermissionGranted(Manifest.permission.RECORD_AUDIO)) { + updateSettings { copy(enableMic = active) } + } else { + pendingPermissionTarget = PendingAudioPermissionTarget.Mic + showRecordAudioPermission = true + } + }, + volume = settings.volumeMic, + onVolumeChange = { updateSettings { copy(volumeMic = it, muteMic = if (it > 0F) false else muteMic) } }, + muted = settings.muteMic, + onMutedChange = { updateSettings { copy(muteMic = it) } }, + modifier = Modifier.padding(top = 4.dp) + ) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + AudioSourceRow( + text = stringResource(R.string.mjpeg_audio_device), + mainIconId = R.drawable.mobile_speaker_24px, + muteIconId = R.drawable.volume_off_24px, + muteIconContentDescription = stringResource(R.string.mjpeg_audio_device_mute), + isStreaming = isStreaming, + active = settings.enableDeviceAudio, + onActiveChange = { active -> + if (active.not() || context.isPermissionGranted(Manifest.permission.RECORD_AUDIO)) { + updateSettings { copy(enableDeviceAudio = active) } + } else { + pendingPermissionTarget = PendingAudioPermissionTarget.DeviceAudio + showRecordAudioPermission = true + } + }, + volume = settings.volumeDeviceAudio, + onVolumeChange = { updateSettings { copy(volumeDeviceAudio = it, muteDeviceAudio = if (it > 0F) false else muteDeviceAudio) } }, + muted = settings.muteDeviceAudio, + onMutedChange = { updateSettings { copy(muteDeviceAudio = it) } }, + modifier = Modifier.padding(top = 4.dp) + ) + } + + HorizontalDivider(modifier = Modifier.padding(top = 8.dp)) + + val audioCapabilities = MjpegAudioEncoderUtils.selectedOpusEncoder?.capabilities?.audioCapabilities + val bitrateRangeKbits = remember(audioCapabilities) { + audioCapabilities?.getBitRateInKbits() ?: 6..510 + } + + BitrateRow( + bitrateRangeKbits = bitrateRangeKbits, + bitrateBits = settings.audioBitrateBits, + onValueChange = { updateSettings { copy(audioBitrateBits = it) } }, + enabled = isStreaming.not(), + modifier = Modifier + .padding(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 8.dp) + .fillMaxWidth() + ) + } + + if (showRecordAudioPermission) { + RequestPermission { isGranted -> + when (pendingPermissionTarget) { + PendingAudioPermissionTarget.Mic -> + updateSettings { copy(enableMic = isGranted) } + + PendingAudioPermissionTarget.DeviceAudio -> + updateSettings { copy(enableDeviceAudio = isGranted) } + + null -> Unit + } + pendingPermissionTarget = null + showRecordAudioPermission = false + } + } +} + +private enum class PendingAudioPermissionTarget { + Mic, + DeviceAudio +} + +@Composable +private fun AudioSourceRow( + text: String, + @DrawableRes mainIconId: Int, + @DrawableRes muteIconId: Int, + muteIconContentDescription: String, + isStreaming: Boolean, + active: Boolean, + onActiveChange: (Boolean) -> Unit, + volume: Float, + onVolumeChange: (Float) -> Unit, + muted: Boolean, + onMutedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Row( + modifier = Modifier + .conditional(isStreaming.not()) { toggleable(value = active, onValueChange = { onActiveChange(it) }) } + .padding(start = 16.dp, top = 8.dp, end = 4.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(painter = painterResource(mainIconId), contentDescription = null) + + Text( + text = text, modifier = Modifier + .weight(1F) + .padding(start = 8.dp) + ) + + Switch(checked = active, onCheckedChange = null, modifier = Modifier.scale(0.7F), enabled = isStreaming.not()) + } + + Row( + modifier = Modifier.padding(start = 48.dp, end = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + var sliderPosition by rememberSaveable(volume, muted) { mutableFloatStateOf(if (muted) 0F else volume.coerceIn(0f, 2f) * 100) } + + Slider( + value = sliderPosition, + onValueChange = { sliderPosition = it }, + modifier = Modifier.weight(1f), + enabled = active, + valueRange = 0f..200f, + onValueChangeFinished = { onVolumeChange((sliderPosition / 100).coerceIn(0f, 2f)) }, + ) + + Text(text = "${sliderPosition.roundToInt()}%", modifier = Modifier.padding(start = 16.dp, end = 8.dp)) + + IconButton( + onClick = { onMutedChange(muted.not()) }, + enabled = active + ) { + Icon( + painter = painterResource(muteIconId), + tint = when { + active.not() -> LocalContentColor.current + muted -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.primary + }, + contentDescription = muteIconContentDescription + ) + } + } + } +} + +@Composable +private fun BitrateRow( + bitrateRangeKbits: ClosedRange, + bitrateBits: Int, + onValueChange: (Int) -> Unit, + enabled: Boolean, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + var isDragging by rememberSaveable { mutableStateOf(false) } + var sliderPosition by rememberSaveable { mutableFloatStateOf((bitrateBits / 1000).coerceIn(bitrateRangeKbits).toFloat()) } + + LaunchedEffect(bitrateBits, bitrateRangeKbits) { + if (!isDragging) { + sliderPosition = (bitrateBits / 1000).coerceIn(bitrateRangeKbits).toFloat() + } + } + + Text(text = stringResource(R.string.mjpeg_audio_bitrate, sliderPosition.roundToInt().toKOrMBitString())) + + Row(modifier = Modifier.fillMaxWidth()) { + Text( + text = bitrateRangeKbits.start.toKOrMBitString(), + modifier = Modifier.align(Alignment.CenterVertically) + ) + Slider( + value = sliderPosition, + onValueChange = { + isDragging = true + sliderPosition = it + }, + modifier = Modifier + .padding(horizontal = 8.dp) + .weight(1f) + .align(Alignment.CenterVertically), + enabled = enabled, + valueRange = bitrateRangeKbits.start.toFloat()..bitrateRangeKbits.endInclusive.toFloat(), + onValueChangeFinished = { + isDragging = false + onValueChange.invoke((sliderPosition * 1000).roundToInt()) + } + ) + Text( + text = bitrateRangeKbits.endInclusive.toKOrMBitString(), + modifier = Modifier.align(Alignment.CenterVertically) + ) + } + } +} + +@Composable +private fun RequestPermission( + permission: String = Manifest.permission.RECORD_AUDIO, + onResult: (Boolean) -> Unit +) { + val context = LocalContext.current + if (context.isPermissionGranted(permission)) { + onResult(true) + return + } + + val activity = remember(context) { context.findActivity() } + var showRationaleDialog by rememberSaveable { mutableStateOf(false) } + var showSettingsDialog by rememberSaveable { mutableStateOf(false) } + + val requestPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (granted) { + onResult(true) + } else { + val showRationale = activity.shouldShowPermissionRationale(permission) + showRationaleDialog = showRationale + showSettingsDialog = showRationale.not() + } + } + + val permissionCheckerObserver = remember { + LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + when { + context.isPermissionGranted(permission) -> onResult(true) + showRationaleDialog.not() && showSettingsDialog.not() -> requestPermissionLauncher.launch(permission) + } + } + } + } + val lifecycle = LocalLifecycleOwner.current.lifecycle + DisposableEffect(lifecycle, permissionCheckerObserver) { + lifecycle.addObserver(permissionCheckerObserver) + onDispose { lifecycle.removeObserver(permissionCheckerObserver) } + } + + if (showRationaleDialog) { + AlertDialog( + onDismissRequest = {}, + confirmButton = { + TextButton( + onClick = { + showRationaleDialog = false + requestPermissionLauncher.launch(permission) + } + ) { + Text(text = stringResource(android.R.string.ok)) + } + }, + dismissButton = { + TextButton( + onClick = { + showRationaleDialog = false + onResult(false) + } + ) { + Text(text = stringResource(android.R.string.cancel)) + } + }, + icon = { Icon(painter = painterResource(R.drawable.mic_24px), contentDescription = null) }, + title = { Text(text = stringResource(R.string.mjpeg_audio_permission_title)) }, + text = { Text(text = stringResource(R.string.mjpeg_audio_permission_message)) }, + shape = MaterialTheme.shapes.large, + properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false) + ) + } + + if (showSettingsDialog) { + AlertDialog( + onDismissRequest = {}, + confirmButton = { + TextButton( + onClick = { + runCatching { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + addCategory(Intent.CATEGORY_DEFAULT) + data = "package:${context.packageName}".toUri() + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY) + addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS) + } + context.startActivity(intent) + }.onFailure { error -> + XLog.e(context.getLog("startActivity", error.toString()), error) + showSettingsDialog = false + onResult(false) + } + } + ) { + Text(text = stringResource(R.string.mjpeg_audio_permission_open_settings)) + } + }, + dismissButton = { + TextButton( + onClick = { + showSettingsDialog = false + onResult(false) + } + ) { + Text(text = stringResource(android.R.string.cancel)) + } + }, + icon = { Icon(painter = painterResource(R.drawable.mic_24px), contentDescription = null) }, + title = { Text(text = stringResource(R.string.mjpeg_audio_permission_title)) }, + text = { Text(text = stringResource(R.string.mjpeg_audio_permission_message_settings)) }, + shape = MaterialTheme.shapes.large, + properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false) + ) + } +} + +@Composable +private fun Int.toKOrMBitString(): String = + if (this >= 1000) stringResource(R.string.mjpeg_audio_bitrate_mbit, this / 1000f) + else stringResource(R.string.mjpeg_audio_bitrate_kbit, this) diff --git a/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/ui/main/cards/GeneralSettingsCard.kt b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/ui/main/cards/GeneralSettingsCard.kt index 2d60b8a2..4e6823da 100644 --- a/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/ui/main/cards/GeneralSettingsCard.kt +++ b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/ui/main/cards/GeneralSettingsCard.kt @@ -23,6 +23,7 @@ import info.dvkr.screenstream.common.ui.ExpandableCard import info.dvkr.screenstream.mjpeg.R import info.dvkr.screenstream.mjpeg.settings.MjpegSettings import info.dvkr.screenstream.mjpeg.ui.main.settings.common.MjpegSettingModal +import info.dvkr.screenstream.mjpeg.ui.main.settings.general.AudioOnlyRow import info.dvkr.screenstream.mjpeg.ui.main.settings.general.HtmlBackColorEditor import info.dvkr.screenstream.mjpeg.ui.main.settings.general.HtmlBackColorRow import info.dvkr.screenstream.mjpeg.ui.main.settings.general.HtmlEnableButtonsRow @@ -33,6 +34,8 @@ import info.dvkr.screenstream.mjpeg.ui.main.settings.general.KeepAwakeRow import info.dvkr.screenstream.mjpeg.ui.main.settings.general.NotifySlowConnectionsRow import info.dvkr.screenstream.mjpeg.ui.main.settings.general.StopOnConfigurationChangeRow import info.dvkr.screenstream.mjpeg.ui.main.settings.general.StopOnSleepRow +import info.dvkr.screenstream.mjpeg.ui.main.settings.general.StreamFormatEditor +import info.dvkr.screenstream.mjpeg.ui.main.settings.general.StreamFormatRow @Composable internal fun GeneralSettingsCard( @@ -43,6 +46,7 @@ internal fun GeneralSettingsCard( ) { var selectedSheet by rememberSaveable { mutableStateOf(null) } val expanded = rememberSaveable { mutableStateOf(false) } + val mp4Selected = settings.streamFormat == MjpegSettings.Values.STREAM_FORMAT_MP4 ExpandableCard( expanded = expanded.value, @@ -68,6 +72,19 @@ internal fun GeneralSettingsCard( HorizontalDivider() } + StreamFormatRow( + streamFormat = settings.streamFormat, + onDetailShow = { selectedSheet = GeneralSettingSheet.StreamFormat } + ) + HorizontalDivider() + + AudioOnlyRow( + streamAudioOnly = mp4Selected && settings.streamAudioOnly, + enabled = mp4Selected, + onValueChange = { newValue -> if (mp4Selected) updateSettings { copy(streamAudioOnly = newValue) } } + ) + HorizontalDivider() + StopOnSleepRow(settings.stopOnSleep) { newValue -> updateSettings { copy(stopOnSleep = newValue) } } @@ -117,6 +134,17 @@ internal fun GeneralSettingsCard( onDismissRequest = { selectedSheet = null } ) { when (sheet) { + GeneralSettingSheet.StreamFormat -> StreamFormatEditor(settings.streamFormat) { value -> + if (settings.streamFormat != value) { + updateSettings { + copy( + streamFormat = value, + streamAudioOnly = if (value == MjpegSettings.Values.STREAM_FORMAT_MP4) streamAudioOnly else false + ) + } + } + } + GeneralSettingSheet.HtmlBackColor -> HtmlBackColorEditor( htmlBackColor = Color(settings.htmlBackColor), onColorChange = { value -> @@ -132,5 +160,6 @@ internal fun GeneralSettingsCard( } private enum class GeneralSettingSheet(@get:StringRes val titleRes: Int) { + StreamFormat(R.string.mjpeg_pref_stream_format), HtmlBackColor(R.string.mjpeg_pref_html_back_color) } diff --git a/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/ui/main/cards/ImageSettingsCard.kt b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/ui/main/cards/ImageSettingsCard.kt index 0b6a9ab0..cb19fe66 100644 --- a/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/ui/main/cards/ImageSettingsCard.kt +++ b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/ui/main/cards/ImageSettingsCard.kt @@ -7,6 +7,7 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable @@ -18,6 +19,8 @@ import androidx.compose.ui.unit.dp import info.dvkr.screenstream.common.module.StreamingModule import info.dvkr.screenstream.common.ui.ExpandableCard import info.dvkr.screenstream.mjpeg.R +import info.dvkr.screenstream.mjpeg.internal.mp4.Mp4VideoEncoderUtils +import info.dvkr.screenstream.mjpeg.internal.mp4.Mp4VideoEncoderUtils.getBitRateInKbits import info.dvkr.screenstream.mjpeg.settings.MjpegSettings import info.dvkr.screenstream.mjpeg.ui.main.settings.common.MjpegSettingModal import info.dvkr.screenstream.mjpeg.ui.main.settings.image.CropImageEditor @@ -33,12 +36,18 @@ import info.dvkr.screenstream.mjpeg.ui.main.settings.image.ResizeImageEditor import info.dvkr.screenstream.mjpeg.ui.main.settings.image.ResizeImageRow import info.dvkr.screenstream.mjpeg.ui.main.settings.image.RotationEditor import info.dvkr.screenstream.mjpeg.ui.main.settings.image.RotationRow +import info.dvkr.screenstream.mjpeg.ui.main.settings.image.VideoBitrateEditor +import info.dvkr.screenstream.mjpeg.ui.main.settings.image.VideoBitrateRow +import info.dvkr.screenstream.mjpeg.ui.main.settings.image.VideoEncoderEditor +import info.dvkr.screenstream.mjpeg.ui.main.settings.image.VideoEncoderRow import info.dvkr.screenstream.mjpeg.ui.main.settings.image.VrModeEditor import info.dvkr.screenstream.mjpeg.ui.main.settings.image.VrModeRow @Composable internal fun ImageSettingsCard( settings: MjpegSettings.Data, + mp4Selected: Boolean, + isStreaming: Boolean, updateSettings: (MjpegSettings.Data.() -> MjpegSettings.Data) -> Unit, windowWidthSizeClass: StreamingModule.WindowWidthSizeClass, modifier: Modifier = Modifier, @@ -46,6 +55,14 @@ internal fun ImageSettingsCard( var selectedSheet by rememberSaveable { mutableStateOf(null) } val expanded = rememberSaveable { mutableStateOf(false) } + LaunchedEffect(mp4Selected) { + selectedSheet = null + } + + val availableVideoEncoders = Mp4VideoEncoderUtils.availableH264Encoders + val selectedVideoEncoder = Mp4VideoEncoderUtils.selectH264Encoder(settings.videoCodecAutoSelect, settings.videoCodec) + val videoBitrateRangeKbits = selectedVideoEncoder?.capabilities?.videoCapabilities?.getBitRateInKbits() ?: 1..240_000 + ExpandableCard( expanded = expanded.value, onExpandedChange = { expanded.value = it }, @@ -56,31 +73,44 @@ internal fun ImageSettingsCard( .padding(start = 48.dp) ) { Text( - text = stringResource(R.string.mjpeg_pref_settings_image), + text = stringResource( + if (mp4Selected) R.string.mjpeg_pref_settings_video + else R.string.mjpeg_pref_settings_image + ), style = MaterialTheme.typography.titleMedium ) } }, modifier = modifier ) { - VrModeRow( - vrMode = settings.vrMode, - onDetailShow = { selectedSheet = ImageSettingSheet.VrMode }, - onValueChange = { value -> updateSettings { copy(vrMode = value) } } - ) - HorizontalDivider() - - CropImageRow( - imageCrop = settings.imageCrop, - onDetailShow = { selectedSheet = ImageSettingSheet.Crop }, - onValueChange = { value -> updateSettings { copy(imageCrop = value) } } - ) - HorizontalDivider() - - GrayscaleRow(settings.imageGrayscale) { value -> - updateSettings { copy(imageGrayscale = value) } + if (mp4Selected.not()) { + VrModeRow( + vrMode = settings.vrMode, + onDetailShow = { selectedSheet = ImageSettingSheet.VrMode }, + onValueChange = { value -> updateSettings { copy(vrMode = value) } } + ) + HorizontalDivider() + + CropImageRow( + imageCrop = settings.imageCrop, + onDetailShow = { selectedSheet = ImageSettingSheet.Crop }, + onValueChange = { value -> updateSettings { copy(imageCrop = value) } } + ) + HorizontalDivider() + + GrayscaleRow(settings.imageGrayscale) { value -> + updateSettings { copy(imageGrayscale = value) } + } + HorizontalDivider() + } else { + VideoEncoderRow( + autoSelect = settings.videoCodecAutoSelect, + selectedEncoder = selectedVideoEncoder, + enabled = isStreaming.not(), + onDetailShow = { selectedSheet = ImageSettingSheet.VideoEncoder } + ) + HorizontalDivider() } - HorizontalDivider() ResizeImageRow( resizeFactor = settings.resizeFactor, @@ -89,81 +119,119 @@ internal fun ImageSettingsCard( ) { selectedSheet = ImageSettingSheet.Resize } HorizontalDivider() - RotationRow(settings.rotation) { selectedSheet = ImageSettingSheet.Rotation } - HorizontalDivider() + if (mp4Selected.not()) { + RotationRow(settings.rotation) { selectedSheet = ImageSettingSheet.Rotation } + HorizontalDivider() - FlipRow( - flipMode = settings.flip, - onDetailShow = { selectedSheet = ImageSettingSheet.Flip }, - onValueChange = { value -> updateSettings { copy(flip = value) } } - ) - HorizontalDivider() + FlipRow( + flipMode = settings.flip, + onDetailShow = { selectedSheet = ImageSettingSheet.Flip }, + onValueChange = { value -> updateSettings { copy(flip = value) } } + ) + HorizontalDivider() + } MaxFpsRow(settings.maxFPS) { selectedSheet = ImageSettingSheet.MaxFps } - HorizontalDivider() - JpegQualityRow(settings.jpegQuality) { selectedSheet = ImageSettingSheet.JpegQuality } - - selectedSheet?.let { sheet -> - MjpegSettingModal( - windowWidthSizeClass = windowWidthSizeClass, - title = stringResource(sheet.titleRes), - onDismissRequest = { selectedSheet = null } - ) { - when (sheet) { - ImageSettingSheet.VrMode -> VrModeEditor(settings.vrMode) { value -> - if (settings.vrMode != value) updateSettings { copy(vrMode = value) } - } - - ImageSettingSheet.Crop -> CropImageEditor( - imageCropTop = settings.imageCropTop, - imageCropBottom = settings.imageCropBottom, - imageCropLeft = settings.imageCropLeft, - imageCropRight = settings.imageCropRight, - onNewValueTop = { value -> if (settings.imageCropTop != value) updateSettings { copy(imageCropTop = value) } }, - onNewValueBottom = { value -> if (settings.imageCropBottom != value) updateSettings { copy(imageCropBottom = value) } }, - onNewValueLeft = { value -> if (settings.imageCropLeft != value) updateSettings { copy(imageCropLeft = value) } }, - onNewValueRight = { value -> if (settings.imageCropRight != value) updateSettings { copy(imageCropRight = value) } } - ) - - ImageSettingSheet.Resize -> ResizeImageEditor( - resizeFactor = settings.resizeFactor, - resolutionWidth = settings.resolutionWidth, - resolutionHeight = settings.resolutionHeight, - stretch = settings.resolutionStretch, - onNewResize = { value -> if (settings.resizeFactor != value) updateSettings { copy(resizeFactor = value) } }, - onNewWidth = { value -> if (settings.resolutionWidth != value) updateSettings { copy(resolutionWidth = value) } }, - onNewHeight = { value -> if (settings.resolutionHeight != value) updateSettings { copy(resolutionHeight = value) } }, - onStretchChange = { value -> if (settings.resolutionStretch != value) updateSettings { copy(resolutionStretch = value) } } - ) - - ImageSettingSheet.Rotation -> RotationEditor(settings.rotation) { value -> - if (settings.rotation != value) updateSettings { copy(rotation = value) } - } - - ImageSettingSheet.Flip -> FlipEditor(settings.flip) { value -> - if (settings.flip != value) updateSettings { copy(flip = value) } - } - - ImageSettingSheet.MaxFps -> MaxFpsEditor(settings.maxFPS) { value -> - if (settings.maxFPS != value) updateSettings { copy(maxFPS = value) } - } + if (mp4Selected) { + HorizontalDivider() + + VideoBitrateRow( + bitrateBits = settings.videoBitrateBits.coerceIn( + videoBitrateRangeKbits.start * 1000, + videoBitrateRangeKbits.endInclusive * 1000 + ), + enabled = isStreaming.not(), + onDetailShow = { selectedSheet = ImageSettingSheet.VideoBitrate } + ) + } else { + HorizontalDivider() + + JpegQualityRow(settings.jpegQuality) { selectedSheet = ImageSettingSheet.JpegQuality } + } - ImageSettingSheet.JpegQuality -> JpegQualityEditor(settings.jpegQuality) { value -> - if (settings.jpegQuality != value) updateSettings { copy(jpegQuality = value) } + selectedSheet + ?.takeIf { sheet -> mp4Selected.not() || sheet in ImageSettingSheet.mp4Sheets } + ?.let { sheet -> + MjpegSettingModal( + windowWidthSizeClass = windowWidthSizeClass, + title = stringResource(sheet.titleRes), + onDismissRequest = { selectedSheet = null } + ) { + when (sheet) { + ImageSettingSheet.VrMode -> VrModeEditor(settings.vrMode) { value -> + if (settings.vrMode != value) updateSettings { copy(vrMode = value) } + } + + ImageSettingSheet.VideoEncoder -> VideoEncoderEditor( + autoSelect = settings.videoCodecAutoSelect, + selectedEncoderName = settings.videoCodec, + availableEncoders = availableVideoEncoders, + onAutoSelect = { updateSettings { copy(videoCodecAutoSelect = true) } }, + onEncoderSelected = { value -> updateSettings { copy(videoCodecAutoSelect = false, videoCodec = value) } } + ) + + ImageSettingSheet.Crop -> CropImageEditor( + imageCropTop = settings.imageCropTop, + imageCropBottom = settings.imageCropBottom, + imageCropLeft = settings.imageCropLeft, + imageCropRight = settings.imageCropRight, + onNewValueTop = { value -> if (settings.imageCropTop != value) updateSettings { copy(imageCropTop = value) } }, + onNewValueBottom = { value -> if (settings.imageCropBottom != value) updateSettings { copy(imageCropBottom = value) } }, + onNewValueLeft = { value -> if (settings.imageCropLeft != value) updateSettings { copy(imageCropLeft = value) } }, + onNewValueRight = { value -> if (settings.imageCropRight != value) updateSettings { copy(imageCropRight = value) } } + ) + + ImageSettingSheet.Resize -> ResizeImageEditor( + resizeFactor = settings.resizeFactor, + resolutionWidth = settings.resolutionWidth, + resolutionHeight = settings.resolutionHeight, + stretch = settings.resolutionStretch, + onNewResize = { value -> if (settings.resizeFactor != value) updateSettings { copy(resizeFactor = value) } }, + onNewWidth = { value -> if (settings.resolutionWidth != value) updateSettings { copy(resolutionWidth = value) } }, + onNewHeight = { value -> if (settings.resolutionHeight != value) updateSettings { copy(resolutionHeight = value) } }, + onStretchChange = { value -> if (settings.resolutionStretch != value) updateSettings { copy(resolutionStretch = value) } } + ) + + ImageSettingSheet.Rotation -> RotationEditor(settings.rotation) { value -> + if (settings.rotation != value) updateSettings { copy(rotation = value) } + } + + ImageSettingSheet.Flip -> FlipEditor(settings.flip) { value -> + if (settings.flip != value) updateSettings { copy(flip = value) } + } + + ImageSettingSheet.MaxFps -> MaxFpsEditor(settings.maxFPS) { value -> + if (settings.maxFPS != value) updateSettings { copy(maxFPS = value) } + } + + ImageSettingSheet.VideoBitrate -> VideoBitrateEditor( + bitrateRangeKbits = videoBitrateRangeKbits, + bitrateBits = settings.videoBitrateBits, + onValueChange = { value -> if (settings.videoBitrateBits != value) updateSettings { copy(videoBitrateBits = value) } } + ) + + ImageSettingSheet.JpegQuality -> JpegQualityEditor(settings.jpegQuality) { value -> + if (settings.jpegQuality != value) updateSettings { copy(jpegQuality = value) } + } } } } - } } } private enum class ImageSettingSheet(@get:StringRes val titleRes: Int) { + VideoEncoder(R.string.mjpeg_video_encoder), VrMode(R.string.mjpeg_pref_vr_mode), Crop(R.string.mjpeg_pref_crop), Resize(R.string.mjpeg_pref_resize), Rotation(R.string.mjpeg_pref_rotate), Flip(R.string.mjpeg_pref_flip), MaxFps(R.string.mjpeg_pref_fps), - JpegQuality(R.string.mjpeg_pref_jpeg_quality) + VideoBitrate(R.string.mjpeg_video_bitrate_title), + JpegQuality(R.string.mjpeg_pref_jpeg_quality); + + companion object { + val mp4Sheets: Set = setOf(VideoEncoder, Resize, MaxFps, VideoBitrate) + } } diff --git a/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/ui/main/settings/general/AudioOnly.kt b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/ui/main/settings/general/AudioOnly.kt new file mode 100644 index 00000000..69431c46 --- /dev/null +++ b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/ui/main/settings/general/AudioOnly.kt @@ -0,0 +1,25 @@ +package info.dvkr.screenstream.mjpeg.ui.main.settings.general + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import info.dvkr.screenstream.mjpeg.R +import info.dvkr.screenstream.mjpeg.ui.main.settings.common.SettingSwitchRow + +@Composable +internal fun AudioOnlyRow( + streamAudioOnly: Boolean, + enabled: Boolean, + onValueChange: (Boolean) -> Unit, + modifier: Modifier = Modifier +) { + SettingSwitchRow( + enabled = enabled, + checked = streamAudioOnly, + iconRes = R.drawable.mobile_speaker_24px, + title = stringResource(R.string.mjpeg_pref_audio_only), + summary = stringResource(R.string.mjpeg_pref_audio_only_summary), + onValueChange = onValueChange, + modifier = modifier + ) +} diff --git a/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/ui/main/settings/general/StreamFormat.kt b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/ui/main/settings/general/StreamFormat.kt new file mode 100644 index 00000000..9c7ae2b2 --- /dev/null +++ b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/ui/main/settings/general/StreamFormat.kt @@ -0,0 +1,54 @@ +package info.dvkr.screenstream.mjpeg.ui.main.settings.general + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import info.dvkr.screenstream.mjpeg.R +import info.dvkr.screenstream.mjpeg.settings.MjpegSettings +import info.dvkr.screenstream.mjpeg.ui.main.settings.common.SelectionEditor +import info.dvkr.screenstream.mjpeg.ui.main.settings.common.SettingValueRow + +@Composable +internal fun StreamFormatRow( + streamFormat: Int, + onDetailShow: () -> Unit +) { + val options = streamFormatOptions() + SettingValueRow( + enabled = true, + iconRes = R.drawable.video_settings_24px, + title = stringResource(R.string.mjpeg_pref_stream_format), + summary = stringResource(R.string.mjpeg_pref_stream_format_summary), + valueText = options[streamFormat.toOptionIndex()], + onClick = onDetailShow + ) +} + +@Composable +internal fun StreamFormatEditor( + streamFormat: Int, + onValueChange: (Int) -> Unit +) { + val options = streamFormatOptions() + SelectionEditor( + options = options, + selectedIndex = streamFormat.toOptionIndex(), + onValueChange = { index -> onValueChange(index.toStreamFormat()) }, + description = stringResource(R.string.mjpeg_pref_stream_format_text) + ) +} + +@Composable +private fun streamFormatOptions(): List = listOf( + stringResource(R.string.mjpeg_pref_stream_format_mjpeg), + stringResource(R.string.mjpeg_pref_stream_format_mp4) +) + +private fun Int.toOptionIndex(): Int = when (this) { + MjpegSettings.Values.STREAM_FORMAT_MP4 -> 1 + else -> 0 +} + +private fun Int.toStreamFormat(): Int = when (this) { + 1 -> MjpegSettings.Values.STREAM_FORMAT_MP4 + else -> MjpegSettings.Values.STREAM_FORMAT_MJPEG +} diff --git a/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/ui/main/settings/image/VideoEncoding.kt b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/ui/main/settings/image/VideoEncoding.kt new file mode 100644 index 00000000..6a4d3521 --- /dev/null +++ b/mjpeg/src/main/java/info/dvkr/screenstream/mjpeg/ui/main/settings/image/VideoEncoding.kt @@ -0,0 +1,139 @@ +package info.dvkr.screenstream.mjpeg.ui.main.settings.image + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import info.dvkr.screenstream.mjpeg.R +import info.dvkr.screenstream.mjpeg.internal.mp4.Mp4VideoEncoderInfo +import info.dvkr.screenstream.mjpeg.ui.main.settings.common.SelectionEditor +import info.dvkr.screenstream.mjpeg.ui.main.settings.common.SettingEditorLayout +import info.dvkr.screenstream.mjpeg.ui.main.settings.common.SettingValueRow +import kotlin.math.roundToInt + +@Composable +internal fun VideoEncoderRow( + autoSelect: Boolean, + selectedEncoder: Mp4VideoEncoderInfo?, + enabled: Boolean, + onDetailShow: () -> Unit +) { + SettingValueRow( + enabled = enabled, + iconRes = R.drawable.video_settings_24px, + title = stringResource(R.string.mjpeg_video_encoder), + summary = selectedEncoder?.let { "[${it.name}]" } ?: stringResource(R.string.mjpeg_video_encoder_summary), + valueText = if (autoSelect) stringResource(R.string.mjpeg_video_encoder_auto) else selectedEncoder?.vendorName, + onClick = onDetailShow + ) +} + +@Composable +internal fun VideoEncoderEditor( + autoSelect: Boolean, + selectedEncoderName: String, + availableEncoders: List, + onAutoSelect: () -> Unit, + onEncoderSelected: (String) -> Unit +) { + val selectedIndex = if (autoSelect) { + 0 + } else { + availableEncoders.indexOfFirst { it.name == selectedEncoderName }.takeIf { it >= 0 }?.plus(1) ?: 0 + } + + SelectionEditor( + options = listOf(stringResource(R.string.mjpeg_video_encoder_auto)) + availableEncoders.map { encoder -> + "${encoder.vendorName} H.264 [${encoder.name}]" + }, + selectedIndex = selectedIndex, + description = stringResource(R.string.mjpeg_video_encoder_summary), + onValueChange = { index -> + if (index == 0) onAutoSelect() + else availableEncoders.getOrNull(index - 1)?.let { onEncoderSelected(it.name) } + } + ) +} + +@Composable +internal fun VideoBitrateRow( + bitrateBits: Int, + enabled: Boolean, + onDetailShow: () -> Unit +) { + SettingValueRow( + enabled = enabled, + iconRes = R.drawable.high_quality_24px, + title = stringResource(R.string.mjpeg_video_bitrate_title), + summary = stringResource(R.string.mjpeg_video_bitrate_summary), + valueText = (bitrateBits / 1000).toKOrMBitString(), + onClick = onDetailShow + ) +} + +@Composable +internal fun VideoBitrateEditor( + bitrateRangeKbits: ClosedRange, + bitrateBits: Int, + onValueChange: (Int) -> Unit +) { + SettingEditorLayout { + var isDragging by rememberSaveable { mutableStateOf(false) } + var sliderPosition by rememberSaveable { mutableFloatStateOf((bitrateBits / 1000).coerceIn(bitrateRangeKbits).toFloat()) } + + LaunchedEffect(bitrateBits, bitrateRangeKbits) { + if (!isDragging) { + sliderPosition = (bitrateBits / 1000).coerceIn(bitrateRangeKbits).toFloat() + } + } + + Text( + text = stringResource(R.string.mjpeg_video_bitrate, sliderPosition.roundToInt().toKOrMBitString()), + modifier = Modifier.fillMaxWidth() + ) + + Row(modifier = Modifier.fillMaxWidth()) { + Text( + text = bitrateRangeKbits.start.toKOrMBitString(), + modifier = Modifier.align(Alignment.CenterVertically) + ) + Slider( + value = sliderPosition, + onValueChange = { + isDragging = true + sliderPosition = it + }, + modifier = Modifier + .padding(horizontal = 8.dp) + .weight(1f) + .align(Alignment.CenterVertically), + valueRange = bitrateRangeKbits.start.toFloat()..bitrateRangeKbits.endInclusive.toFloat(), + onValueChangeFinished = { + isDragging = false + onValueChange(sliderPosition.roundToInt() * 1000) + } + ) + Text( + text = bitrateRangeKbits.endInclusive.toKOrMBitString(), + modifier = Modifier.align(Alignment.CenterVertically) + ) + } + } +} + +@Composable +private fun Int.toKOrMBitString(): String = + if (this >= 1000) stringResource(R.string.mjpeg_video_bitrate_mbit, this / 1000f) + else stringResource(R.string.mjpeg_video_bitrate_kbit, this) diff --git a/mjpeg/src/main/res/drawable/mic_24px.xml b/mjpeg/src/main/res/drawable/mic_24px.xml new file mode 100644 index 00000000..4850a8ec --- /dev/null +++ b/mjpeg/src/main/res/drawable/mic_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/mjpeg/src/main/res/drawable/mic_off_24px.xml b/mjpeg/src/main/res/drawable/mic_off_24px.xml new file mode 100644 index 00000000..dd6d09b1 --- /dev/null +++ b/mjpeg/src/main/res/drawable/mic_off_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/mjpeg/src/main/res/drawable/mobile_speaker_24px.xml b/mjpeg/src/main/res/drawable/mobile_speaker_24px.xml new file mode 100644 index 00000000..464e4e9b --- /dev/null +++ b/mjpeg/src/main/res/drawable/mobile_speaker_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/mjpeg/src/main/res/drawable/volume_off_24px.xml b/mjpeg/src/main/res/drawable/volume_off_24px.xml new file mode 100644 index 00000000..560fc4d5 --- /dev/null +++ b/mjpeg/src/main/res/drawable/volume_off_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/mjpeg/src/main/res/values-af/strings.xml b/mjpeg/src/main/res/values-af/strings.xml index 6701d34b..31b2694b 100644 --- a/mjpeg/src/main/res/values-af/strings.xml +++ b/mjpeg/src/main/res/values-af/strings.xml @@ -3,7 +3,7 @@ Maak instellings oop Toemaak - Plaaslike modus · MJPEG + Plaaslike modus · %1$s Stroom oor jou plaaslike netwerk - Gebruik MJPEG.\n @@ -65,8 +65,37 @@ Kies agtergrondkleur Pas prent aan by blaaier venster Skaleer prent in blaaier na volle vensterbreedte/hoogte + Stream format + Choose local stream video format + MP4 uses H.264 video and AAC audio in one browser stream. MJPEG streams images only. + MJPEG + MP4 + Audio only + Stream audio without screen video + + Audio settings + Microphone + Mute microphone + Device audio + Mute device audio + Audio bitrate: %1$s + %.1f Mbit/s + %d Kbit/s + Audio permission required + Allow audio recording to stream microphone or device audio. + Audio permission is blocked. Enable microphone permission in app settings. + Open settings Beeldinstellings + Video-instellings + Video encoder + Select H.264 encoder for MP4 streams + Auto + Video bitrate + Set MP4 video bitrate + Video bitrate: %1$s + %.1f Mbit/s + %d Kbit/s Virtuele werklikheidsmodus Vang inhoud van halwe skerm af @@ -87,6 +116,8 @@ Skakel beeld na grysskaal om voor stuur Verander grootte van prent Verander grootte van prent voordat dit na kliënt gestuur word + %1$d%% + %1$dx%2$d Verander grootte volgens persentasie Presiese resolusie Breedte: @@ -96,6 +127,7 @@ Prentgrootte: %1$dx%2$d Draai prent kloksgewys Draai prent voordat dit na kliënt gestuur word + %1$d\u00B0 Spieël prent Spieël prent voordat dit na kliënt gestuur word @@ -149,6 +181,7 @@ Publieke IPv4 Publieke IPv6 Loopback + %1$s (%2$s) Bedienerpoort Stel poort vir inkomende verbindings Stel bedienerpoort vir inkomende verbindings.\nWaardes: 1025–65535\nVerstek: 8080 diff --git a/mjpeg/src/main/res/values-am/strings.xml b/mjpeg/src/main/res/values-am/strings.xml index 62ef4676..62a79f15 100644 --- a/mjpeg/src/main/res/values-am/strings.xml +++ b/mjpeg/src/main/res/values-am/strings.xml @@ -3,7 +3,7 @@ ቅንብሮችን ይክፈቱ ዝጋ - የአካባቢ ሁነታ · MJPEG + የአካባቢ ሁነታ · %1$s በአካባቢያዊ አውታረ መረብዎ ላይ ያስተላልፉ - MJPEG ይጠቀማል።\n @@ -65,8 +65,37 @@ የጀርባ ቀለም ይምረጡ ምስልን እንደ መሣሪያ መስኮት ይስማማ አበረክቷት ምስልን በአጠቃቀም ስልክ ወደ ሙሉ መስኮት ስፋት/ቁመት + Stream format + Choose local stream video format + MP4 uses H.264 video and AAC audio in one browser stream. MJPEG streams images only. + MJPEG + MP4 + Audio only + Stream audio without screen video + + Audio settings + Microphone + Mute microphone + Device audio + Mute device audio + Audio bitrate: %1$s + %.1f Mbit/s + %d Kbit/s + Audio permission required + Allow audio recording to stream microphone or device audio. + Audio permission is blocked. Enable microphone permission in app settings. + Open settings የምስል ቅንብሮች + የቪዲዮ ቅንብሮች + Video encoder + Select H.264 encoder for MP4 streams + Auto + Video bitrate + Set MP4 video bitrate + Video bitrate: %1$s + %.1f Mbit/s + %d Kbit/s ምናባዊ እውነታ ሁነታ ይዘትን ከግማሽ ማያ ገጽ ያንሱ @@ -87,6 +116,8 @@ ከመላክዎ በፊት ምስሉን ወደ ግራጫ ይቀይሩ የምስል መጠን ቀይር ወደ ደንበኛ ከመላክዎ በፊት የምስሉን መጠን ይለውጡ + %1$d%% + %1$dx%2$d በመቶኛ መጠን ቀይር ትክክለኛ ጥራት ስፋት: @@ -96,6 +127,7 @@ የሥዕል መጠን፡ %1$dx%2$d ምስሉን በሰዓት አቅጣጫ አሽከርክር ወደ ደንበኛ ከመላክዎ በፊት ምስሉን ያሽከርክሩ + %1$d\u00B0 ምስሉን ገልብጥ ወደ ደንበኛ ከመላክዎ በፊት ምስሉን ገልብጡ @@ -149,6 +181,7 @@ ህዝባዊ IPv4 ህዝባዊ IPv6 Loopback + %1$s (%2$s) የአገልጋይ ወደብ ለገቢ ግንኙነቶች ወደብ ያዘጋጁ ለመጪ ግንኙነቶች የአገልጋይ ወደብ ያዘጋጁ።\nዋጋ፡ 1025–65535\nነባሪ፡ 8080 diff --git a/mjpeg/src/main/res/values-ar/strings.xml b/mjpeg/src/main/res/values-ar/strings.xml index 823c260f..ff8eb53e 100644 --- a/mjpeg/src/main/res/values-ar/strings.xml +++ b/mjpeg/src/main/res/values-ar/strings.xml @@ -3,7 +3,7 @@ فتح الإعدادات إغلاق - الوضع المحلي · MJPEG + الوضع المحلي · %1$s البث عبر شبكتك المحلية - يستخدم MJPEG.\n @@ -65,8 +65,37 @@ اختيار لون الخلفية ملاءمة الصورة لنافذة المتصفح تكبير الصورة في المتصفح لملء عرض/ارتفاع النافذة بالكامل + Stream format + Choose local stream video format + MP4 uses H.264 video and AAC audio in one browser stream. MJPEG streams images only. + MJPEG + MP4 + Audio only + Stream audio without screen video + + Audio settings + Microphone + Mute microphone + Device audio + Mute device audio + Audio bitrate: %1$s + %.1f Mbit/s + %d Kbit/s + Audio permission required + Allow audio recording to stream microphone or device audio. + Audio permission is blocked. Enable microphone permission in app settings. + Open settings إعدادات الصورة + إعدادات الفيديو + Video encoder + Select H.264 encoder for MP4 streams + Auto + Video bitrate + Set MP4 video bitrate + Video bitrate: %1$s + %.1f Mbit/s + %d Kbit/s وضع الواقع الافتراضي التقاط المحتوى من نصف الشاشة @@ -87,6 +116,8 @@ حوّل الصورة إلى تدرج رمادي قبل الإرسال تغيير حجم الصورة تغيير حجم الصورة قبل إرسالها إلى العميل + %1$d%% + %1$dx%2$d تغيير الحجم بالنسبة المئوية دقة محددة العرض: @@ -96,6 +127,7 @@ حجم الصورة: %1$dx%2$d تدوير الصورة في اتجاه عقارب الساعة تدوير الصورة قبل إرسالها إلى العميل + %1$d\u00B0 اقلب الصورة اقلب الصورة قبل إرسالها إلى العميل @@ -149,6 +181,7 @@ IPv4 عام IPv6 عام حلقة محلية + %1$s (%2$s) منفذ الخادم تعيين منفذ للاتصالات الواردة تعيين منفذ الخادم للاتصالات الواردة.\nالقيم: 1025–65535\nالافتراضي: 8080 diff --git a/mjpeg/src/main/res/values-bn/strings.xml b/mjpeg/src/main/res/values-bn/strings.xml index 403e83a1..dc3a1703 100644 --- a/mjpeg/src/main/res/values-bn/strings.xml +++ b/mjpeg/src/main/res/values-bn/strings.xml @@ -3,7 +3,7 @@ সেটিংস খুলুন বন্ধ করুন - স্থানীয় মোড · MJPEG + স্থানীয় মোড · %1$s আপনার স্থানীয় নেটওয়ার্কে স্ট্রিম করুন - MJPEG ব্যবহার করে।\n @@ -65,8 +65,37 @@ পটভূমির রঙ নির্বাচন করুন ব্রাউজারের উইন্ডোতে ছবিটি ফিট করুন ব্রাউজারে ছবিটি সম্পূর্ণ উইন্ডো প্রস্থ/উচ্চতায় স্কেল করুন + Stream format + Choose local stream video format + MP4 uses H.264 video and AAC audio in one browser stream. MJPEG streams images only. + MJPEG + MP4 + Audio only + Stream audio without screen video + + Audio settings + Microphone + Mute microphone + Device audio + Mute device audio + Audio bitrate: %1$s + %.1f Mbit/s + %d Kbit/s + Audio permission required + Allow audio recording to stream microphone or device audio. + Audio permission is blocked. Enable microphone permission in app settings. + Open settings ছবি সেটিংস + ভিডিও সেটিংস + Video encoder + Select H.264 encoder for MP4 streams + Auto + Video bitrate + Set MP4 video bitrate + Video bitrate: %1$s + %.1f Mbit/s + %d Kbit/s ভার্চুয়াল বাস্তবতা মোড অর্ধ স্ক্রীন থেকে বিষয়বস্তু ক্যাপচার @@ -87,6 +116,8 @@ পাঠানোর আগে ছবিকে গ্রেস্কেলে রূপান্তর করুন চিত্রের আকার পরিবর্তন করুন ক্লায়েন্টকে পাঠানোর আগে চিত্রের আকার পরিবর্তন করুন + %1$d%% + %1$dx%2$d শতাংশ অনুযায়ী আকার বদলান নির্দিষ্ট রেজোলিউশন প্রস্থ: @@ -96,6 +127,7 @@ ছবির আকার: %1$dx%2$d ছবি ঘড়ির কাঁটার দিকে ঘোরান ক্লায়েন্টকে পাঠানোর আগে চিত্রটি ঘোরান + %1$d\u00B0 ছবি উল্টান ক্লায়েন্টে পাঠানোর আগে ছবি উল্টান @@ -149,6 +181,7 @@ পাবলিক IPv4 পাবলিক IPv6 লুপব্যাক + %1$s (%2$s) সার্ভারের পোর্ট ইনকামিং সংযোগের জন্য পোর্ট সেট করুন ইনকামিং সংযোগের জন্য সার্ভার পোর্ট সেট করুন।\nমান: 1025–65535\nডিফল্ট: 8080 diff --git a/mjpeg/src/main/res/values-de/strings.xml b/mjpeg/src/main/res/values-de/strings.xml index 1f8efbfd..52ee41ca 100644 --- a/mjpeg/src/main/res/values-de/strings.xml +++ b/mjpeg/src/main/res/values-de/strings.xml @@ -3,7 +3,7 @@ Einstellungen öffnen Schließen - Lokaler Modus · MJPEG + Lokaler Modus · %1$s Über Ihr lokales Netzwerk streamen - Verwendet MJPEG.\n @@ -65,8 +65,37 @@ Hintergrundfarbe wählen Bild an Browserfenster anpassen Bild im Browser auf volle Fensterbreite/-höhe skalieren + Stream format + Choose local stream video format + MP4 uses H.264 video and AAC audio in one browser stream. MJPEG streams images only. + MJPEG + MP4 + Audio only + Stream audio without screen video + + Audio settings + Microphone + Mute microphone + Device audio + Mute device audio + Audio bitrate: %1$s + %.1f Mbit/s + %d Kbit/s + Audio permission required + Allow audio recording to stream microphone or device audio. + Audio permission is blocked. Enable microphone permission in app settings. + Open settings Bildeinstellungen + Videoeinstellungen + Video encoder + Select H.264 encoder for MP4 streams + Auto + Video bitrate + Set MP4 video bitrate + Video bitrate: %1$s + %.1f Mbit/s + %d Kbit/s Modus für virtuelle Realität Erfassen von Inhalten vom halben Bildschirm @@ -87,6 +116,8 @@ Bild vor dem Senden in Graustufen umwandeln Bildgröße anpassen Bildgröße vor dem Senden anpassen + %1$d%% + %1$dx%2$d Nach Prozentsatz skalieren Exakte Auflösung Breite: @@ -96,6 +127,7 @@ Bildgröße: %1$dx%2$d Bild im Uhrzeigersinn drehen Bild vor dem Senden drehen + %1$d\u00B0 Bild spiegeln Bild vor dem Senden spiegeln @@ -149,6 +181,7 @@ Öffentliche IPv4 Öffentliche IPv6 Loopback + %1$s (%2$s) Server-Port Port für eingehende Verbindungen setzen Port für eingehende Verbindungen setzen.\nWerte: 1025–65535\nStandard: 8080 diff --git a/mjpeg/src/main/res/values-es/strings.xml b/mjpeg/src/main/res/values-es/strings.xml index f282ef20..337d1b3a 100644 --- a/mjpeg/src/main/res/values-es/strings.xml +++ b/mjpeg/src/main/res/values-es/strings.xml @@ -3,7 +3,7 @@ Abrir ajustes Cerrar - Modo local · MJPEG + Modo local · %1$s Transmitir por la red local - Usa MJPEG.\n @@ -65,8 +65,37 @@ Seleccionar color de fondo Ajustar imagen a la ventana del navegador Escalar la imagen en el navegador a la anchura/altura completa de la ventana + Stream format + Choose local stream video format + MP4 uses H.264 video and AAC audio in one browser stream. MJPEG streams images only. + MJPEG + MP4 + Audio only + Stream audio without screen video + + Audio settings + Microphone + Mute microphone + Device audio + Mute device audio + Audio bitrate: %1$s + %.1f Mbit/s + %d Kbit/s + Audio permission required + Allow audio recording to stream microphone or device audio. + Audio permission is blocked. Enable microphone permission in app settings. + Open settings Ajustes de imagen + Ajustes de vídeo + Video encoder + Select H.264 encoder for MP4 streams + Auto + Video bitrate + Set MP4 video bitrate + Video bitrate: %1$s + %.1f Mbit/s + %d Kbit/s Modo Realidad Virtual Capturar contenido en modo de realidad aumentada @@ -87,6 +116,8 @@ Convertir imagen a escala de grises antes de enviar Redimensionar la imagen Redimensionar la imagen antes de enviar al cliente + %1$d%% + %1$dx%2$d Redimensionar por porcentaje Resolución exacta Ancho: @@ -96,6 +127,7 @@ Tamaño de imagen: %1$dx%2$d Rotar imagen en sentido del reloj Rotar imagen antes de enviar al cliente + %1$d\u00B0 Voltear imagen Voltear imagen antes de enviar al cliente @@ -149,6 +181,7 @@ IPv4 pública IPv6 pública Loopback + %1$s (%2$s) Puerto del Servidor Puerto para conexiones entrantes Defina el puerto del servidor para conexiones entrantes.\nValores: 1025–65535\nPredeterminado: 8080 diff --git a/mjpeg/src/main/res/values-eu/strings.xml b/mjpeg/src/main/res/values-eu/strings.xml index 91874318..df92d887 100644 --- a/mjpeg/src/main/res/values-eu/strings.xml +++ b/mjpeg/src/main/res/values-eu/strings.xml @@ -3,7 +3,7 @@ Ireki ezarpenak Itzi - Tokiko modua · MJPEG + Tokiko modua · %1$s Streamatu zure sare lokalean - MJPEG erabiltzen du.\n @@ -65,8 +65,37 @@ Hautatu atzeko planoaren kolorea Doitu irudia nabigatzailearen leihoari Eskalatu nabigatzailearen irudia leihoaren zabalera/altuerari + Stream format + Choose local stream video format + MP4 uses H.264 video and AAC audio in one browser stream. MJPEG streams images only. + MJPEG + MP4 + Audio only + Stream audio without screen video + + Audio settings + Microphone + Mute microphone + Device audio + Mute device audio + Audio bitrate: %1$s + %.1f Mbit/s + %d Kbit/s + Audio permission required + Allow audio recording to stream microphone or device audio. + Audio permission is blocked. Enable microphone permission in app settings. + Open settings Irudi-ezarpenak + Bideo-ezarpenak + Video encoder + Select H.264 encoder for MP4 streams + Auto + Video bitrate + Set MP4 video bitrate + Video bitrate: %1$s + %.1f Mbit/s + %d Kbit/s Errealitate Birtuala modua Kapturatu edukia errealitate areagotuko moduan @@ -87,6 +116,8 @@ Bihurtu irudia gris-eskalara bidali aurretik Aldatu irudiaren tamaina Aldatu irudiaren tamaina bezeroari bidali aurretik + %1$d%% + %1$dx%2$d Aldatu tamaina ehunekoaren arabera Bereizmen zehatza Zabalera: @@ -96,6 +127,7 @@ Irudiaren tamaina: %1$dx%2$d Biratu irudia erlojuaren orratzen noranzkoan Biratu irudia bezeroari bidali aurretik + %1$d\u00B0 Irudia irauli Irudia bezeroari bidali aurretik irauli @@ -149,6 +181,7 @@ IPv4 publikoa IPv6 publikoa Loopback + %1$s (%2$s) Zerbitzariaren ataka Sarrerako konexioetarako ataka Sarrerako konexioetarako ataka.\nBalioak: 1025–65535\nLehenetsia: 8080 diff --git a/mjpeg/src/main/res/values-fr/strings.xml b/mjpeg/src/main/res/values-fr/strings.xml index 98f0da40..d30f8acb 100644 --- a/mjpeg/src/main/res/values-fr/strings.xml +++ b/mjpeg/src/main/res/values-fr/strings.xml @@ -3,7 +3,7 @@ Ouvrir les paramètres Fermer - Mode local · MJPEG + Mode local · %1$s Diffuser sur le réseau local - Utilise MJPEG.\n @@ -65,8 +65,37 @@ Sélectionner la couleur de fond Ajuster l\'image à la fenêtre du navigateur Agrandir l\'image dans le navigateur à la largeur/hauteur complète de la fenêtre + Stream format + Choose local stream video format + MP4 uses H.264 video and AAC audio in one browser stream. MJPEG streams images only. + MJPEG + MP4 + Audio only + Stream audio without screen video + + Audio settings + Microphone + Mute microphone + Device audio + Mute device audio + Audio bitrate: %1$s + %.1f Mbit/s + %d Kbit/s + Audio permission required + Allow audio recording to stream microphone or device audio. + Audio permission is blocked. Enable microphone permission in app settings. + Open settings Paramètres d\'image + Paramètres vidéo + Video encoder + Select H.264 encoder for MP4 streams + Auto + Video bitrate + Set MP4 video bitrate + Video bitrate: %1$s + %.1f Mbit/s + %d Kbit/s Mode réalité virtuelle Capturer le contenu à partir de la moitié de l\'écran @@ -87,6 +116,8 @@ Convertir l\'image en niveaux de gris avant l\'envoi Redimensionner l\'image Redimensionner l\'image avant de l\'envoyer au client + %1$d%% + %1$dx%2$d Redimensionner par pourcentage Résolution exacte Largeur : @@ -96,6 +127,7 @@ Taille de l\'image : %1$dx%2$d Faire pivoter l\'image dans le sens horaire Faire pivoter l\'image avant de l\'envoyer au client + %1$d\u00B0 Retourner l\'image Retourner l\'image avant de l\'envoyer au client @@ -149,6 +181,7 @@ IPv4 public IPv6 public Loopback + %1$s (%2$s) Port du serveur Définir le port pour les connexions entrantes Définir le port du serveur pour les connexions entrantes.\nValeurs : 1025–65535\nPar défaut : 8080 diff --git a/mjpeg/src/main/res/values-hi/strings.xml b/mjpeg/src/main/res/values-hi/strings.xml index cc2cd3f1..c4f8521c 100644 --- a/mjpeg/src/main/res/values-hi/strings.xml +++ b/mjpeg/src/main/res/values-hi/strings.xml @@ -3,7 +3,7 @@ सेटिंग्स खोलें बंद करें - स्थानीय मोड · MJPEG + स्थानीय मोड · %1$s अपने स्थानीय नेटवर्क पर स्ट्रीम करें - MJPEG का उपयोग करता है।\n @@ -65,8 +65,37 @@ पृष्ठभूमि का रंग चुनें छवि को ब्राउज़र विंडो में फिट करें ब्राउज़र में छवि को पूरी विंडो चौड़ाई/ऊँचाई में बढ़ाएँ + Stream format + Choose local stream video format + MP4 uses H.264 video and AAC audio in one browser stream. MJPEG streams images only. + MJPEG + MP4 + Audio only + Stream audio without screen video + + Audio settings + Microphone + Mute microphone + Device audio + Mute device audio + Audio bitrate: %1$s + %.1f Mbit/s + %d Kbit/s + Audio permission required + Allow audio recording to stream microphone or device audio. + Audio permission is blocked. Enable microphone permission in app settings. + Open settings छवि सेटिंग्स + वीडियो सेटिंग्स + Video encoder + Select H.264 encoder for MP4 streams + Auto + Video bitrate + Set MP4 video bitrate + Video bitrate: %1$s + %.1f Mbit/s + %d Kbit/s आभासी वास्तविकता मोड आधे स्क्रीन से सामग्री कैप्चर करें @@ -87,6 +116,8 @@ भेजने से पहले छवि को ग्रेस्केल में बदलें चित्र को पुनर्कार करें क्लाइंट को भेजने से पहले छवि का आकार बदलें + %1$d%% + %1$dx%2$d प्रतिशत से आकार बदलें सटीक रिज़ॉल्यूशन चौड़ाई: @@ -96,6 +127,7 @@ चित्र का आकार: %1$dx%2$d छवि को दक्षिणावर्त घुमाएँ क्लाइंट को भेजने से पहले छवि को घुमाएं + %1$d\u00B0 छवि पलटें क्लाइंट को भेजने से पहले छवि पलटें @@ -149,6 +181,7 @@ सार्वजनिक IPv4 सार्वजनिक IPv6 लूपबैक + %1$s (%2$s) सर्वर पोर्ट आने वाले कनेक्शन के लिए पोर्ट सेट करें आने वाले कनेक्शन के लिए सर्वर पोर्ट सेट करें।\nमूल्य: 1025–65535\nडिफ़ॉल्ट: 8080 diff --git a/mjpeg/src/main/res/values-in/strings.xml b/mjpeg/src/main/res/values-in/strings.xml index 37ce7075..ef41c334 100644 --- a/mjpeg/src/main/res/values-in/strings.xml +++ b/mjpeg/src/main/res/values-in/strings.xml @@ -3,7 +3,7 @@ Buka pengaturan Tutup - Mode lokal · MJPEG + Mode lokal · %1$s Streaming melalui jaringan lokal Anda - Menggunakan MJPEG.\n @@ -65,8 +65,37 @@ Pilih warna latar belakang Sesuaikan gambar ke jendela browser Perbesar gambar di browser ke lebar/tinggi penuh jendela + Stream format + Choose local stream video format + MP4 uses H.264 video and AAC audio in one browser stream. MJPEG streams images only. + MJPEG + MP4 + Audio only + Stream audio without screen video + + Audio settings + Microphone + Mute microphone + Device audio + Mute device audio + Audio bitrate: %1$s + %.1f Mbit/s + %d Kbit/s + Audio permission required + Allow audio recording to stream microphone or device audio. + Audio permission is blocked. Enable microphone permission in app settings. + Open settings Setelan gambar + Setelan video + Video encoder + Select H.264 encoder for MP4 streams + Auto + Video bitrate + Set MP4 video bitrate + Video bitrate: %1$s + %.1f Mbit/s + %d Kbit/s Modus realitas maya Tangkap konten dari setengah layar @@ -87,6 +116,8 @@ Ubah gambar ke skala abu-abu sebelum dikirim Ubah ukuran gambar Ubah ukuran gambar sebelum dikirim ke klien + %1$d%% + %1$dx%2$d Ubah ukuran berdasarkan persentase Resolusi tepat Lebar: @@ -96,6 +127,7 @@ Ukuran gambar: %1$dx%2$d Putar gambar searah jarum jam Putar gambar sebelum dikirim ke klien + %1$d\u00B0 Balik gambar Balik gambar sebelum mengirim ke klien @@ -149,6 +181,7 @@ IPv4 publik IPv6 publik Loopback + %1$s (%2$s) Port server Atur port untuk koneksi masuk Atur port server untuk koneksi masuk.\nNilai: 1025–65535\nBawaan: 8080 diff --git a/mjpeg/src/main/res/values-it/strings.xml b/mjpeg/src/main/res/values-it/strings.xml index 1e3cc32a..3db2e04c 100644 --- a/mjpeg/src/main/res/values-it/strings.xml +++ b/mjpeg/src/main/res/values-it/strings.xml @@ -3,7 +3,7 @@ Apri impostazioni Chiudi - Modalità locale · MJPEG + Modalità locale · %1$s Trasmetti sulla rete locale - Usa MJPEG.\n @@ -65,8 +65,37 @@ Seleziona colore di sfondo Adatta immagine alla finestra del browser Scala l\'immagine nel browser a larghezza/altezza completa della finestra + Stream format + Choose local stream video format + MP4 uses H.264 video and AAC audio in one browser stream. MJPEG streams images only. + MJPEG + MP4 + Audio only + Stream audio without screen video + + Audio settings + Microphone + Mute microphone + Device audio + Mute device audio + Audio bitrate: %1$s + %.1f Mbit/s + %d Kbit/s + Audio permission required + Allow audio recording to stream microphone or device audio. + Audio permission is blocked. Enable microphone permission in app settings. + Open settings Impostazioni immagine + Impostazioni video + Video encoder + Select H.264 encoder for MP4 streams + Auto + Video bitrate + Set MP4 video bitrate + Video bitrate: %1$s + %.1f Mbit/s + %d Kbit/s Modalità realtà virtuale Cattura contenuto da metà schermo @@ -87,6 +116,8 @@ Converti l\'immagine in scala di grigi prima dell\'invio Ridimensiona immagine Ridimensiona l\'immagine prima di inviarla al client + %1$d%% + %1$dx%2$d Ridimensiona per percentuale Risoluzione esatta Larghezza: @@ -96,6 +127,7 @@ Dimensione immagine: %1$dx%2$d Ruota immagine in senso orario Ruota l\'immagine prima di inviarla al client + %1$d\u00B0 Capovolgi immagine Capovolgi l\'immagine prima di inviarla al client @@ -149,6 +181,7 @@ IPv4 pubblico IPv6 pubblico Loopback + %1$s (%2$s) Porta server Imposta la porta per le connessioni in ingresso Imposta la porta per le connessioni in ingresso.\nValori: 1025–65535\nPredefinita: 8080 diff --git a/mjpeg/src/main/res/values-ja/strings.xml b/mjpeg/src/main/res/values-ja/strings.xml index 19b7afb7..db3bb48e 100644 --- a/mjpeg/src/main/res/values-ja/strings.xml +++ b/mjpeg/src/main/res/values-ja/strings.xml @@ -3,7 +3,7 @@ 設定を開く 閉じる - ローカルモード · MJPEG + ローカルモード · %1$s ローカルネットワークでストリーミング - MJPEGを使用します。\n @@ -65,8 +65,37 @@ 背景色を選択してください 画像をブラウザウィンドウに合わせる 画像をブラウザでウィンドウの全幅/全高に拡大する + Stream format + Choose local stream video format + MP4 uses H.264 video and AAC audio in one browser stream. MJPEG streams images only. + MJPEG + MP4 + Audio only + Stream audio without screen video + + Audio settings + Microphone + Mute microphone + Device audio + Mute device audio + Audio bitrate: %1$s + %.1f Mbit/s + %d Kbit/s + Audio permission required + Allow audio recording to stream microphone or device audio. + Audio permission is blocked. Enable microphone permission in app settings. + Open settings 画像設定 + 動画設定 + Video encoder + Select H.264 encoder for MP4 streams + Auto + Video bitrate + Set MP4 video bitrate + Video bitrate: %1$s + %.1f Mbit/s + %d Kbit/s VRモード 画面の半分をキャプチャします @@ -87,6 +116,8 @@ 送信前に画像をグレースケールに変換 画像のリサイズ クライアントに送信する前に画像サイズを変更します + %1$d%% + %1$dx%2$d パーセンテージでリサイズ 正確な解像度 幅: @@ -96,6 +127,7 @@ 画像サイズ: %1$dx%2$d 時計回りに画像を回転する クライアントに送信する前に画像を回転します + %1$d\u00B0 画像を反転 送信前に画像を反転します @@ -149,6 +181,7 @@ パブリックIPv4 パブリックIPv6 ループバック + %1$s (%2$s) サーバー ポート 接続を待ち受けるポートを設定します 接続を待ち受けるポートを設定してください。\n値: 1025–65535\n初期値: 8080 diff --git a/mjpeg/src/main/res/values-jv/strings.xml b/mjpeg/src/main/res/values-jv/strings.xml index de7b39eb..37ed9527 100644 --- a/mjpeg/src/main/res/values-jv/strings.xml +++ b/mjpeg/src/main/res/values-jv/strings.xml @@ -3,7 +3,7 @@ Bukak setelan Nutup - Mode lokal · MJPEG + Mode lokal · %1$s Streaming liwat jaringan lokal - Nggunakake MJPEG.\n @@ -65,8 +65,37 @@ Pilih werna latar mburi Pas gambar menyang jendhela browser Skala gambar ing browser menyang kebekan jendhela + Stream format + Choose local stream video format + MP4 uses H.264 video and AAC audio in one browser stream. MJPEG streams images only. + MJPEG + MP4 + Audio only + Stream audio without screen video + + Audio settings + Microphone + Mute microphone + Device audio + Mute device audio + Audio bitrate: %1$s + %.1f Mbit/s + %d Kbit/s + Audio permission required + Allow audio recording to stream microphone or device audio. + Audio permission is blocked. Enable microphone permission in app settings. + Open settings Setelan gambar + Setelan video + Video encoder + Select H.264 encoder for MP4 streams + Auto + Video bitrate + Set MP4 video bitrate + Video bitrate: %1$s + %.1f Mbit/s + %d Kbit/s Mode kasunyatan virtual Jupuk isi saka setengah layar @@ -87,6 +116,8 @@ Owahi gambar dadi grayscale sadurunge dikirim Ngowahi ukuran gambar Ganti ukuran gambar sadurunge dikirim menyang klien + %1$d%% + %1$dx%2$d Ganti ukuran miturut persentase Resolusi pas Amba: @@ -96,6 +127,7 @@ Ukuran gambar: %1$dx%2$d Puter gambar searah jarum jam Puter gambar sadurunge dikirim menyang klien + %1$d\u00B0 Balik gambar Balik gambar sadurunge dikirim menyang klien @@ -149,6 +181,7 @@ IPv4 umum IPv6 umum Loopback + %1$s (%2$s) Port server Setel port kanggo sambungan mlebu Setel port server kanggo sambungan mlebu.\nAngka: 1025–65535\nDefault: 8080 diff --git a/mjpeg/src/main/res/values-ka/strings.xml b/mjpeg/src/main/res/values-ka/strings.xml index 867c6475..0fb99f07 100644 --- a/mjpeg/src/main/res/values-ka/strings.xml +++ b/mjpeg/src/main/res/values-ka/strings.xml @@ -3,7 +3,7 @@ გახსენით პარამეტრები დახურვა - ლოკალური რეჟიმი · MJPEG + ლოკალური რეჟიმი · %1$s სტრიმინგი თქვენს ლოკალურ ქსელში - იყენებს MJPEG-ს.\n @@ -65,8 +65,37 @@ აირჩიეთ ფონის ფერი გამოსახულების მორგება ბრაუზერის ფანჯარაში გამოსახულების მასშტაბირება ბრაუზერში სრულ ეკრანზე + Stream format + Choose local stream video format + MP4 uses H.264 video and AAC audio in one browser stream. MJPEG streams images only. + MJPEG + MP4 + Audio only + Stream audio without screen video + + Audio settings + Microphone + Mute microphone + Device audio + Mute device audio + Audio bitrate: %1$s + %.1f Mbit/s + %d Kbit/s + Audio permission required + Allow audio recording to stream microphone or device audio. + Audio permission is blocked. Enable microphone permission in app settings. + Open settings გამოსახულების პარამეტრები + ვიდეოს პარამეტრები + Video encoder + Select H.264 encoder for MP4 streams + Auto + Video bitrate + Set MP4 video bitrate + Video bitrate: %1$s + %.1f Mbit/s + %d Kbit/s ვირტუალური რეალობის რეჟიმი გადაიღეთ შინაარსი ნახევარი ეკრანიდან @@ -87,6 +116,8 @@ გაგზავნამდე გამოსახულების ნაცრისფერში გადაყვანა სურათის ზომის შეცვლა შეცვალეთ სურათის ზომა კლიენტისთვის გაგზავნამდე + %1$d%% + %1$dx%2$d ზომის შეცვლა პროცენტით ზუსტი გარჩევადობა სიგანე: @@ -96,6 +127,7 @@ სურათის ზომა: %1$dx%2$d სურათის როტაცია საათის ისრის მიმართულებით დაატრიალეთ სურათი კლიენტისთვის გაგზავნამდე + %1$d\u00B0 სურათის გადაბრუნება სურათის გადაბრუნება კლიენტისთვის გაგზავნამდე @@ -149,6 +181,7 @@ საჯარო IPv4 საჯარო IPv6 Loopback + %1$s (%2$s) Სერვერის პორტი დააყენეთ პორტი შემომავალი კავშირებისთვის დააყენეთ სერვერის პორტი შემომავალი კავშირებისთვის.\nღირებულებები: 1025–65535\nნაგულისხმევი: 8080 diff --git a/mjpeg/src/main/res/values-nl/strings.xml b/mjpeg/src/main/res/values-nl/strings.xml index 0f42f37a..104f97d5 100644 --- a/mjpeg/src/main/res/values-nl/strings.xml +++ b/mjpeg/src/main/res/values-nl/strings.xml @@ -3,7 +3,7 @@ Instellingen openen Sluiten - Lokale modus · MJPEG + Lokale modus · %1$s Streamen via uw lokale netwerk - Gebruikt MJPEG.\n @@ -65,8 +65,37 @@ Selecteer pagina achtergrond kleur Pas afbeelding aan browservenster aan Vergroot afbeelding in browser naar volledige vensterbreedte/-hoogte + Stream format + Choose local stream video format + MP4 uses H.264 video and AAC audio in one browser stream. MJPEG streams images only. + MJPEG + MP4 + Audio only + Stream audio without screen video + + Audio settings + Microphone + Mute microphone + Device audio + Mute device audio + Audio bitrate: %1$s + %.1f Mbit/s + %d Kbit/s + Audio permission required + Allow audio recording to stream microphone or device audio. + Audio permission is blocked. Enable microphone permission in app settings. + Open settings Afbeeldingsinstellingen + Video-instellingen + Video encoder + Select H.264 encoder for MP4 streams + Auto + Video bitrate + Set MP4 video bitrate + Video bitrate: %1$s + %.1f Mbit/s + %d Kbit/s Virtual Reality modus Neem het halve scherm op @@ -87,6 +116,8 @@ Afbeelding voor verzending omzetten naar grijswaarden Afbeeldingsformaat wijzigen Afbeelding schalen voordat deze naar de client wordt verzonden + %1$d%% + %1$dx%2$d Schalen op percentage Exacte resolutie Breedte: @@ -96,6 +127,7 @@ Afbeeldingsgrootte: %1$dx%2$d Afbeelding rechtsom draaien Afbeelding draaien voordat deze naar de client wordt verzonden + %1$d\u00B0 Afbeelding spiegelen Afbeelding spiegelen voor het sturen naar de client @@ -149,6 +181,7 @@ Publiek IPv4 Publiek IPv6 Loopback + %1$s (%2$s) Serverpoort Poort voor inkomende verbindingen instellen Stel de serverpoort in voor inkomende verbindingen.\nWaarden: 1025–65535\nStandaard: 8080 diff --git a/mjpeg/src/main/res/values-pl/strings.xml b/mjpeg/src/main/res/values-pl/strings.xml index f6936da4..4a60fd65 100644 --- a/mjpeg/src/main/res/values-pl/strings.xml +++ b/mjpeg/src/main/res/values-pl/strings.xml @@ -3,7 +3,7 @@ Otwórz ustawienia Zamknij - Tryb lokalny · MJPEG + Tryb lokalny · %1$s Strumieniuj w sieci lokalnej - Używa MJPEG.\n @@ -65,8 +65,37 @@ Wybierz kolor tła Dopasuj obraz do okna przeglądarki Powiększ obraz w przeglądarce do pełnej szerokości/wysokości okna + Stream format + Choose local stream video format + MP4 uses H.264 video and AAC audio in one browser stream. MJPEG streams images only. + MJPEG + MP4 + Audio only + Stream audio without screen video + + Audio settings + Microphone + Mute microphone + Device audio + Mute device audio + Audio bitrate: %1$s + %.1f Mbit/s + %d Kbit/s + Audio permission required + Allow audio recording to stream microphone or device audio. + Audio permission is blocked. Enable microphone permission in app settings. + Open settings Ustawienia obrazu + Ustawienia wideo + Video encoder + Select H.264 encoder for MP4 streams + Auto + Video bitrate + Set MP4 video bitrate + Video bitrate: %1$s + %.1f Mbit/s + %d Kbit/s Tryb wirtualnej rzeczywistości Przechwytuje zawartość z połowy ekranu @@ -87,6 +116,8 @@ Konwertuj obraz na odcienie szarości przed wysłaniem Zmiana rozmiaru obrazu Zmienia rozmiar obrazu przed wysłaniem + %1$d%% + %1$dx%2$d Zmień rozmiar procentowo Dokładna rozdzielczość Szerokość: @@ -96,6 +127,7 @@ Rozmiar obrazu: %1$dx%2$d Obróć zdjęcie zgodnie z ruchem wskazówek zegara Obraca obraz przed wysłaniem + %1$d\u00B0 Odbij obraz Odbij obraz przed wysłaniem @@ -149,6 +181,7 @@ Publiczny IPv4 Publiczny IPv6 Loopback + %1$s (%2$s) Port serwera Ustawianie portu dla połączeń przychodzących Ustaw numer portu serwera dla połączeń przychodzących.\nWartość: 1025–65535\nDomyślnie: 8080 diff --git a/mjpeg/src/main/res/values-pt/strings.xml b/mjpeg/src/main/res/values-pt/strings.xml index d6ad7a66..1dcf7d64 100644 --- a/mjpeg/src/main/res/values-pt/strings.xml +++ b/mjpeg/src/main/res/values-pt/strings.xml @@ -3,7 +3,7 @@ Abrir configurações Fechar - Modo local · MJPEG + Modo local · %1$s Transmitir pela rede local - Usa MJPEG.\n @@ -65,8 +65,37 @@ Selecionar a cor de fundo Ajustar imagem à janela do navegador Redimensionar imagem no navegador para largura/altura total da janela + Stream format + Choose local stream video format + MP4 uses H.264 video and AAC audio in one browser stream. MJPEG streams images only. + MJPEG + MP4 + Audio only + Stream audio without screen video + + Audio settings + Microphone + Mute microphone + Device audio + Mute device audio + Audio bitrate: %1$s + %.1f Mbit/s + %d Kbit/s + Audio permission required + Allow audio recording to stream microphone or device audio. + Audio permission is blocked. Enable microphone permission in app settings. + Open settings Configurações de imagem + Configurações de vídeo + Video encoder + Select H.264 encoder for MP4 streams + Auto + Video bitrate + Set MP4 video bitrate + Video bitrate: %1$s + %.1f Mbit/s + %d Kbit/s Modo de realidade virtual Capturar conteúdo da metade da tela @@ -87,6 +116,8 @@ Converter a imagem em tons de cinza antes de enviar Redimensionar imagem Redimensionar a imagem antes de enviá-la para o cliente + %1$d%% + %1$dx%2$d Redimensionar por porcentagem Resolução exata Largura: @@ -96,6 +127,7 @@ Tamanho da imagem: %1$dx%2$d Girar imagem no sentido horário Girar a imagem antes de enviá-la para o cliente + %1$d\u00B0 Inverter imagem Inverter a imagem antes de enviá-la ao cliente @@ -149,6 +181,7 @@ IPv4 público IPv6 público Loopback + %1$s (%2$s) Porta do servidor Definir a porta para conexões de entrada Definir a porta do servidor para conexões de entrada.\nValores: 1025–65535\nPadrão: 8080 diff --git a/mjpeg/src/main/res/values-ru/strings.xml b/mjpeg/src/main/res/values-ru/strings.xml index d32bff1a..975975f2 100644 --- a/mjpeg/src/main/res/values-ru/strings.xml +++ b/mjpeg/src/main/res/values-ru/strings.xml @@ -3,7 +3,7 @@ Открыть настройки Закрыть - Локальный режим · MJPEG + Локальный режим · %1$s Трансляция по локальной сети - Использует MJPEG.\n @@ -65,8 +65,37 @@ Выберите цвет фона Масштабировать изображение под окно браузера Масштабировать изображение в браузере до полной ширины/высоты окна + Stream format + Choose local stream video format + MP4 uses H.264 video and AAC audio in one browser stream. MJPEG streams images only. + MJPEG + MP4 + Audio only + Stream audio without screen video + + Audio settings + Microphone + Mute microphone + Device audio + Mute device audio + Audio bitrate: %1$s + %.1f Mbit/s + %d Kbit/s + Audio permission required + Allow audio recording to stream microphone or device audio. + Audio permission is blocked. Enable microphone permission in app settings. + Open settings Настройки изображения + Настройки видео + Video encoder + Select H.264 encoder for MP4 streams + Auto + Video bitrate + Set MP4 video bitrate + Video bitrate: %1$s + %.1f Mbit/s + %d Kbit/s Режим виртуальной реальности Захват контента с половины экрана @@ -87,6 +116,8 @@ Преобразовать изображение в оттенки серого перед отправкой Размер изображения Изменять размер изображения перед отправкой + %1$d%% + %1$dx%2$d Изменить размер в процентах Точное разрешение Ширина: @@ -96,6 +127,7 @@ Размер изображения: %1$dx%2$d Поворачивать по часовой стрелке Поворачивать изображение перед отправкой + %1$d\u00B0 Отразить изображение Отразить изображение перед отправкой @@ -149,6 +181,7 @@ Публичный IPv4 Публичный IPv6 Loopback + %1$s (%2$s) Порт сервера Установить порт для входящих подключений Установите порт сервера для входящих подключений.\nЗначения: 1025–65535\nПо умолчанию: 8080 diff --git a/mjpeg/src/main/res/values-tr/strings.xml b/mjpeg/src/main/res/values-tr/strings.xml index 94ed9c48..1e866262 100644 --- a/mjpeg/src/main/res/values-tr/strings.xml +++ b/mjpeg/src/main/res/values-tr/strings.xml @@ -3,7 +3,7 @@ Ayarları aç Kapat - Yerel mod · MJPEG + Yerel mod · %1$s Yerel ağınızda yayın yapın - MJPEG kullanır.\n @@ -65,8 +65,37 @@ Arka plan rengini seç Görüntüyü tarayıcı penceresine sığdır Tarayıcıda görüntüyü pencerenin tam genişlik/yüksekliğine ölçekle + Stream format + Choose local stream video format + MP4 uses H.264 video and AAC audio in one browser stream. MJPEG streams images only. + MJPEG + MP4 + Audio only + Stream audio without screen video + + Audio settings + Microphone + Mute microphone + Device audio + Mute device audio + Audio bitrate: %1$s + %.1f Mbit/s + %d Kbit/s + Audio permission required + Allow audio recording to stream microphone or device audio. + Audio permission is blocked. Enable microphone permission in app settings. + Open settings Görüntü ayarları + Video ayarları + Video encoder + Select H.264 encoder for MP4 streams + Auto + Video bitrate + Set MP4 video bitrate + Video bitrate: %1$s + %.1f Mbit/s + %d Kbit/s Sanal gerçeklik modu İçeriği ekranın yarısından yakala @@ -87,6 +116,8 @@ Göndermeden önce görüntüyü gri tonlamaya çevir Görüntüyü yeniden boyutlandır İstemciye göndermeden önce görüntüyü yeniden boyutlandır + %1$d%% + %1$dx%2$d Yüzdeye göre yeniden boyutlandır Tam çözünürlük Genişlik: @@ -96,6 +127,7 @@ Resim boyutu: %1$dx%2$d Görüntüyü saat yönünde döndür İstemciye göndermeden önce görüntüyü döndür + %1$d\u00B0 Görüntüyü çevir İstemciye göndermeden önce görüntüyü çevir @@ -149,6 +181,7 @@ Genel IPv4 Genel IPv6 Loopback + %1$s (%2$s) Sunucu bağlantı noktası Gelen bağlantılar için bağlantı noktasını ayarla Gelen bağlantılar için sunucu bağlantı noktasını ayarlayın.\nDeğerler: 1025–65535\nVarsayılan: 8080 diff --git a/mjpeg/src/main/res/values-uk/strings.xml b/mjpeg/src/main/res/values-uk/strings.xml index 5946809b..ee246b0f 100644 --- a/mjpeg/src/main/res/values-uk/strings.xml +++ b/mjpeg/src/main/res/values-uk/strings.xml @@ -3,7 +3,7 @@ Відкрити налаштування Закрити - Локальний режим · MJPEG + Локальний режим · %1$s Трансляція через локальну мережу - Використовує MJPEG.\n @@ -65,8 +65,37 @@ Вибрати колір фону Масштабувати зображення під вікно браузера Масштабувати зображення у браузері до повної ширини/висоти вікна + Stream format + Choose local stream video format + MP4 uses H.264 video and AAC audio in one browser stream. MJPEG streams images only. + MJPEG + MP4 + Audio only + Stream audio without screen video + + Audio settings + Microphone + Mute microphone + Device audio + Mute device audio + Audio bitrate: %1$s + %.1f Mbit/s + %d Kbit/s + Audio permission required + Allow audio recording to stream microphone or device audio. + Audio permission is blocked. Enable microphone permission in app settings. + Open settings Налаштування зображення + Налаштування відео + Video encoder + Select H.264 encoder for MP4 streams + Auto + Video bitrate + Set MP4 video bitrate + Video bitrate: %1$s + %.1f Mbit/s + %d Kbit/s Режим віртуальної реальності Захоплювати лише половину екрану @@ -87,6 +116,8 @@ Перетворити зображення у відтінки сірого перед надсиланням Змінити розмір зображення Змінити розмір зображення перед надсиланням на клієнт + %1$d%% + %1$dx%2$d Змінити розмір у відсотках Точна роздільність Ширина: @@ -96,6 +127,7 @@ Розмір зображення: %1$dx%2$d Повернути зображення за годинниковаою стрілкою Повернути зображення перед надсиланням на клієнт + %1$d\u00B0 Віддзеркалити зображення Віддзеркалити зображення перед надсиланням на клієнт @@ -149,6 +181,7 @@ Публічний IPv4 Публічний IPv6 Loopback + %1$s (%2$s) Порт сервера Встановити порт для вхідних підключень Встановити порт для вхідних підключень.\nВеличини: 1025–65535\nЗа замовчуванням: 8080 diff --git a/mjpeg/src/main/res/values-ur/strings.xml b/mjpeg/src/main/res/values-ur/strings.xml index fef8427c..0c1399ee 100644 --- a/mjpeg/src/main/res/values-ur/strings.xml +++ b/mjpeg/src/main/res/values-ur/strings.xml @@ -3,7 +3,7 @@ ترتیبات کھولیں بند کریں - لوکل موڈ · MJPEG + لوکل موڈ · %1$s اپنے مقامی نیٹ ورک پر اسٹریم کریں - MJPEG استعمال کرتا ہے۔\n @@ -65,8 +65,37 @@ پس منظر رنگ منتخب کریں تصویر کو ونڈو میں فٹ کریں براؤزر میں تصویر کو پوری ونڈو کے مطابق کریں + Stream format + Choose local stream video format + MP4 uses H.264 video and AAC audio in one browser stream. MJPEG streams images only. + MJPEG + MP4 + Audio only + Stream audio without screen video + + Audio settings + Microphone + Mute microphone + Device audio + Mute device audio + Audio bitrate: %1$s + %.1f Mbit/s + %d Kbit/s + Audio permission required + Allow audio recording to stream microphone or device audio. + Audio permission is blocked. Enable microphone permission in app settings. + Open settings تصویر کی ترتیبات + ویڈیو کی ترتیبات + Video encoder + Select H.264 encoder for MP4 streams + Auto + Video bitrate + Set MP4 video bitrate + Video bitrate: %1$s + %.1f Mbit/s + %d Kbit/s VR موڈ آدھی اسکرین سے کیپچر کریں @@ -87,6 +116,8 @@ بھیجنے سے پہلے تصویر کو گرے اسکیل میں تبدیل کریں تصویر کا سائز بدلیں کلائنٹ کو بھیجنے سے پہلے سائز بدلیں + %1$d%% + %1$dx%2$d فیصد کے حساب سے سائز بدلیں مقرر ریزولوشن چوڑائی: @@ -96,6 +127,7 @@ تصویر سائز: %1$dx%2$d تصویر دائیں گھمائیں کلائنٹ کو بھیجنے سے پہلے گھمائیں + %1$d\u00B0 تصویر پلٹیں کلائنٹ کو بھیجنے سے پہلے تصویر پلٹیں @@ -149,6 +181,7 @@ عوامی IPv4 عوامی IPv6 لوپ بیک + %1$s (%2$s) سرور پورٹ آنے والے کنکشنز کیلئے پورٹ سیٹ کریں آنے والے کنکشنز کیلئے سرور پورٹ سیٹ کریں۔\nقدریں: 1025–65535\nڈیفالٹ: 8080 diff --git a/mjpeg/src/main/res/values-uz/strings.xml b/mjpeg/src/main/res/values-uz/strings.xml index 1bc15dfd..8c24d8b1 100644 --- a/mjpeg/src/main/res/values-uz/strings.xml +++ b/mjpeg/src/main/res/values-uz/strings.xml @@ -3,7 +3,7 @@ Sozlamalarni oching Yopish - Mahalliy rejim · MJPEG + Mahalliy rejim · %1$s Mahalliy tarmog‘ingiz orqali translatsiya qiling - MJPEG ishlatadi.\n @@ -65,8 +65,37 @@ Fon rangini tanlang Rasmni brauzer oynasiga moslashtiring Rasmni brauzerda to\'liq oynani kengligi/balandligi bo\'yicha kattalashtiring + Stream format + Choose local stream video format + MP4 uses H.264 video and AAC audio in one browser stream. MJPEG streams images only. + MJPEG + MP4 + Audio only + Stream audio without screen video + + Audio settings + Microphone + Mute microphone + Device audio + Mute device audio + Audio bitrate: %1$s + %.1f Mbit/s + %d Kbit/s + Audio permission required + Allow audio recording to stream microphone or device audio. + Audio permission is blocked. Enable microphone permission in app settings. + Open settings Rasm sozlamalari + Video sozlamalari + Video encoder + Select H.264 encoder for MP4 streams + Auto + Video bitrate + Set MP4 video bitrate + Video bitrate: %1$s + %.1f Mbit/s + %d Kbit/s Virtual haqiqat rejimi Yarim ekrandan tarkibni yozib oling @@ -87,6 +116,8 @@ Yuborishdan oldin rasmni kul rangga aylantirish Rasm hajmini o\'zgartirish Mijozga yuborishdan oldin rasm hajmini o\'zgartiring + %1$d%% + %1$dx%2$d Foiz bo\'yicha o\'lchamini o\'zgartirish Aniq piksellar soni Kengligi: @@ -96,6 +127,7 @@ Rasm hajmi: %1$dx%2$d Rasmni soat yo\'nalishi bo\'yicha aylantiring Mijozga yuborishdan oldin tasvirni aylantiring + %1$d\u00B0 Rasmni ag\'daring Mijozga yuborishdan oldin tasvirni ag\'daring @@ -149,6 +181,7 @@ Ommaviy IPv4 Ommaviy IPv6 Loopback + %1$s (%2$s) Server porti Kiruvchi ulanishlar uchun portni o\'rnating Kiruvchi ulanishlar uchun server portini o\'rnating.\nQiymatlar: 1025–65535\nStandart: 8080 diff --git a/mjpeg/src/main/res/values-zh-rTW/strings.xml b/mjpeg/src/main/res/values-zh-rTW/strings.xml index a385326f..8db8f5e4 100644 --- a/mjpeg/src/main/res/values-zh-rTW/strings.xml +++ b/mjpeg/src/main/res/values-zh-rTW/strings.xml @@ -3,7 +3,7 @@ 開啟設定 關閉 - 本地模式 · MJPEG + 本地模式 · %1$s 透過區域網路串流 - 使用 MJPEG。\n @@ -65,8 +65,37 @@ 選擇背景色 適應瀏覽器視窗的圖片 在瀏覽器中縮放圖片以適應整個視窗的寬度/高度 + Stream format + Choose local stream video format + MP4 uses H.264 video and AAC audio in one browser stream. MJPEG streams images only. + MJPEG + MP4 + Audio only + Stream audio without screen video + + Audio settings + Microphone + Mute microphone + Device audio + Mute device audio + Audio bitrate: %1$s + %.1f Mbit/s + %d Kbit/s + Audio permission required + Allow audio recording to stream microphone or device audio. + Audio permission is blocked. Enable microphone permission in app settings. + Open settings 影像設定 + 影片設定 + Video encoder + Select H.264 encoder for MP4 streams + Auto + Video bitrate + Set MP4 video bitrate + Video bitrate: %1$s + %.1f Mbit/s + %d Kbit/s 虛擬實境模式 截取半個螢幕 @@ -87,6 +116,8 @@ 傳送前將影像轉為灰階 縮放畫面 傳送縮放過的畫面到用戶端 + %1$d%% + %1$dx%2$d 按百分比調整大小 精確解析度 寬度: @@ -96,6 +127,7 @@ 畫面大小:%1$dx%2$d 順時針旋轉畫面 傳送順時針旋轉過的畫面到用戶端 + %1$d\u00B0 翻轉畫面 傳送到用戶端前先翻轉畫面 @@ -149,6 +181,7 @@ 公用IPv4 公用IPv6 迴路 + %1$s (%2$s) 伺服器連接埠 設定連接埠 設定連入使用的連接埠號。\n範圍: 1025–65535\n預設: 8080 diff --git a/mjpeg/src/main/res/values-zh/strings.xml b/mjpeg/src/main/res/values-zh/strings.xml index d024fcf7..7c254758 100644 --- a/mjpeg/src/main/res/values-zh/strings.xml +++ b/mjpeg/src/main/res/values-zh/strings.xml @@ -3,7 +3,7 @@ 打开设置 关闭 - 本地模式 · MJPEG + 本地模式 · %1$s 通过本地网络串流 - 使用 MJPEG。\n @@ -65,8 +65,37 @@ 选择背景色 适应浏览器窗口的图像 将图像在浏览器中缩放到整个窗口宽度/高度 + Stream format + Choose local stream video format + MP4 uses H.264 video and AAC audio in one browser stream. MJPEG streams images only. + MJPEG + MP4 + Audio only + Stream audio without screen video + + Audio settings + Microphone + Mute microphone + Device audio + Mute device audio + Audio bitrate: %1$s + %.1f Mbit/s + %d Kbit/s + Audio permission required + Allow audio recording to stream microphone or device audio. + Audio permission is blocked. Enable microphone permission in app settings. + Open settings 图像设置 + 视频设置 + Video encoder + Select H.264 encoder for MP4 streams + Auto + Video bitrate + Set MP4 video bitrate + Video bitrate: %1$s + %.1f Mbit/s + %d Kbit/s 虚拟现实模式 从半个屏幕捕获内容 @@ -87,6 +116,8 @@ 发送前将图像转为灰度 调整图片尺寸 发送到客户端前调整图片尺寸 + %1$d%% + %1$dx%2$d 按百分比调整大小 精确分辨率 宽度: @@ -96,6 +127,7 @@ 图片尺寸: %1$dx%2$d 顺时针旋转图片 发送到客户端前先旋转图片 + %1$d\u00B0 翻转图片 发送到客户端前翻转图片 @@ -149,6 +181,7 @@ 公网IPv4 公网IPv6 环回 + %1$s (%2$s) 服务器端口 设置传入连接的端口 设置传入连接的服务器端口。\n值的范围:1025–65535\n默认:8080 diff --git a/mjpeg/src/main/res/values/strings.xml b/mjpeg/src/main/res/values/strings.xml index 52af9580..b2aa8e67 100644 --- a/mjpeg/src/main/res/values/strings.xml +++ b/mjpeg/src/main/res/values/strings.xml @@ -3,7 +3,7 @@ Open settings Close - Local mode · MJPEG + Local mode · %1$s Stream over your local network - Uses MJPEG.\n @@ -65,8 +65,37 @@ Select background color Fit image to browser window Scale up image in browser to full window width/height + Stream format + Choose local stream video format + MP4 uses H.264 video and AAC audio in one browser stream. MJPEG streams images only. + MJPEG + MP4 + Audio only + Stream audio without screen video + + Audio settings + Microphone + Mute microphone + Device audio + Mute device audio + Audio bitrate: %1$s + %.1f Mbit/s + %d Kbit/s + Audio permission required + Allow audio recording to stream microphone or device audio. + Audio permission is blocked. Enable microphone permission in app settings. + Open settings Image settings + Video settings + Video encoder + Select H.264 encoder for MP4 streams + Auto + Video bitrate + Set MP4 video bitrate + Video bitrate: %1$s + %.1f Mbit/s + %d Kbit/s Virtual reality mode Capture content from half screen diff --git a/rtsp/src/main/java/info/dvkr/screenstream/rtsp/RtspStreamingModule.kt b/rtsp/src/main/java/info/dvkr/screenstream/rtsp/RtspStreamingModule.kt index cd89c3ff..2aca071f 100644 --- a/rtsp/src/main/java/info/dvkr/screenstream/rtsp/RtspStreamingModule.kt +++ b/rtsp/src/main/java/info/dvkr/screenstream/rtsp/RtspStreamingModule.kt @@ -229,6 +229,7 @@ public class RtspStreamingModule : StreamingModule { is RtspEvent.StartProjection, is RtspEvent.CastPermissionsDenied, is RtspEvent.Intentable.StopStream, + RtspStreamingService.InternalEvent.StartMicrophoneAudioOnlyStream, is RtspStreamingService.InternalEvent.StartStream -> XLog.i(getLog("sendEvent", "Ignoring stale event in state=$state: $event")) diff --git a/rtsp/src/main/java/info/dvkr/screenstream/rtsp/internal/RtspStreamingService.kt b/rtsp/src/main/java/info/dvkr/screenstream/rtsp/internal/RtspStreamingService.kt index 6e88430d..b185b52c 100644 --- a/rtsp/src/main/java/info/dvkr/screenstream/rtsp/internal/RtspStreamingService.kt +++ b/rtsp/src/main/java/info/dvkr/screenstream/rtsp/internal/RtspStreamingService.kt @@ -1,10 +1,13 @@ package info.dvkr.screenstream.rtsp.internal import android.Manifest +import android.app.ForegroundServiceTypeException +import android.app.ServiceStartNotAllowedException import android.content.ComponentCallbacks import android.content.Intent import android.content.pm.ActivityInfo import android.content.pm.PackageManager +import android.content.pm.ServiceInfo import android.content.res.Configuration import android.hardware.display.DisplayManager import android.hardware.display.VirtualDisplay @@ -135,6 +138,18 @@ internal class RtspStreamingService( service.stopForeground() } + private fun startForegroundForMicrophoneOnly(): Throwable? { + val foregroundServiceType = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE else 0 + return runCatching { service.startForeground(foregroundServiceType) }.exceptionOrNull() + } + + private fun Throwable.toForegroundStartFailGroup(): StartFailGroup = when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && this is ServiceStartNotAllowedException -> StartFailGroup.BLOCKED + Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && this is ForegroundServiceTypeException -> StartFailGroup.FATAL + else -> StartFailGroup.FATAL + } + private val sessionAnalyticsTracker by lazy(LazyThreadSafetyMode.NONE) { StreamingSessionAnalyticsTracker( analytics = streamingAnalytics, @@ -149,26 +164,28 @@ internal class RtspStreamingService( } private class ActiveProjection( - val mediaProjection: MediaProjection, - val virtualDisplay: VirtualDisplay, - val videoEncoder: VideoEncoder, - var captureSurface: Surface, + val mediaProjection: MediaProjection?, + val virtualDisplay: VirtualDisplay?, + val videoEncoder: VideoEncoder?, + var captureSurface: Surface?, var audioEncoder: AudioEncoder? = null, var deviceConfiguration: Configuration, val onVideoReconfigureStart: () -> Unit = {} ) { fun stop(projectionCallback: MediaProjection.Callback) { - videoEncoder.stop() - virtualDisplay.surface = null - virtualDisplay.release() - runCatching { captureSurface.release() } + videoEncoder?.stop() + virtualDisplay?.surface = null + virtualDisplay?.release() + runCatching { captureSurface?.release() } audioEncoder?.stop() - mediaProjection.unregisterCallback(projectionCallback) + mediaProjection?.unregisterCallback(projectionCallback) } fun reconfigureVideo(width: Int, height: Int, fps: Int, bitRate: Int, densityDpi: Int) { + val videoEncoder = requireNotNull(videoEncoder) { "Video encoder is not active" } + val virtualDisplay = requireNotNull(virtualDisplay) { "Virtual display is not active" } val oldSurface = captureSurface onVideoReconfigureStart() virtualDisplay.surface = null @@ -179,7 +196,7 @@ internal class RtspStreamingService( virtualDisplay.resize(width, height, densityDpi) virtualDisplay.surface = newSurface captureSurface = newSurface - runCatching { oldSurface.release() } + runCatching { oldSurface?.release() } videoEncoder.start() } } @@ -510,12 +527,15 @@ internal class RtspStreamingService( data class OnAudioCodecChange(val name: String?) : InternalEvent(Priority.DESTROY_IGNORE) data class ModeChanged(val mode: RtspSettings.Values.Mode) : InternalEvent(Priority.RECOVER_IGNORE) data class StartStream(val permissionEducationShown: Boolean) : InternalEvent(Priority.RECOVER_IGNORE) + data object StartMicrophoneAudioOnlyStream : InternalEvent(Priority.RECOVER_IGNORE) data object RetryBindings : InternalEvent(Priority.RECOVER_IGNORE) data class AudioCaptureError(val cause: Throwable) : InternalEvent(Priority.RECOVER_IGNORE) data class OnAudioParamsChange(val micMute: Boolean, val deviceMute: Boolean, val micVolume: Float, val deviceVolume: Float) : InternalEvent(Priority.DESTROY_IGNORE) + data object RefreshState : InternalEvent(Priority.DESTROY_IGNORE) + data class ConfigurationChange(val newConfig: Configuration) : InternalEvent(Priority.RECOVER_IGNORE) { override fun toString(): String = "ConfigurationChange" } @@ -609,6 +629,9 @@ internal class RtspStreamingService( rtspSettings.data.map { InternalEvent.OnAudioParamsChange(it.muteMic, it.muteDeviceAudio, it.volumeMic, it.volumeDeviceAudio) } .listenForChange(coroutineScope) { sendEvent(it) } + rtspSettings.data.map { Triple(it.streamAudioOnly, it.enableMic, it.enableDeviceAudio) } + .listenForChange(coroutineScope, 1) { sendEvent(InternalEvent.RefreshState) } + rtspSettings.data.map { it.mode }.listenForChange(coroutineScope, 1) { mode -> if (!settingsLoaded) { settingsLoaded = true @@ -674,6 +697,7 @@ internal class RtspStreamingService( if (destroyPending) { when (event) { is InternalEvent.StartStream, + InternalEvent.StartMicrophoneAudioOnlyStream, is RtspEvent.CastPermissionsDenied, is RtspEvent.StartProjection -> sessionAnalyticsTracker.onStartAborted() } @@ -720,7 +744,18 @@ internal class RtspStreamingService( clientController?.status ?: RtspClientStatus.IDLE } val readinessBusy = when (selectedMode) { - RtspSettings.Values.Mode.SERVER -> serverActive.not() + RtspSettings.Values.Mode.SERVER -> { + val settings = rtspSettings.data.value + val audioEnabled = settings.enableMic || settings.enableDeviceAudio + val videoReady = settings.streamAudioOnly || selectedVideoEncoderInfo != null + val audioReady = if (settings.streamAudioOnly) { + audioEnabled && selectedAudioEncoderInfo != null + } else { + audioEnabled.not() || selectedAudioEncoderInfo != null + } + (serverActive && videoReady && audioReady).not() + } + RtspSettings.Values.Mode.CLIENT -> { val audioEnabled = rtspSettings.data.value.enableMic || rtspSettings.data.value.enableDeviceAudio val clientReady = clientController != null @@ -747,6 +782,68 @@ internal class RtspStreamingService( ) } + private fun createAndStartAudioEncoder( + settings: RtspSettings.Data, + enableMic: Boolean, + enableDeviceAudio: Boolean, + mediaProjection: MediaProjection?, + setAudioParams: (AudioParams?) -> Unit, + onFrame: (MediaFrame) -> Unit + ): AudioEncoder { + val audioEncoderInfo = requireNotNull(selectedAudioEncoderInfo) { "No audio encoder selected" } + return AudioEncoder( + codecInfo = audioEncoderInfo, + onAudioInfo = { params -> + val audioParams = AudioParams(audioEncoderInfo.codec, params.sampleRate, params.isStereo) + projectionState.lastAudioParams = audioParams + setAudioParams(audioParams) + }, + onAudioFrame = onFrame, + onAudioCaptureError = { sendEvent(InternalEvent.AudioCaptureError(it)) }, + onError = { + XLog.w(getLog("AudioEncoder.onError", it.message), it) + sendEvent(InternalEvent.Error(it.toAudioPipelineError())) + } + ).apply { + val requestedBitrate = settings.audioBitrateBits + val requestedStereo = settings.stereoAudio + val paramsFromSettings = when (audioEncoderInfo.codec) { + is Codec.Audio.G711 -> AudioSource.Params.DEFAULT_G711.copy( + bitrate = 64 * 1000, + echoCanceler = settings.audioEchoCanceller, + noiseSuppressor = settings.audioNoiseSuppressor + ) + + is Codec.Audio.OPUS -> AudioSource.Params.DEFAULT_OPUS.copy( + bitrate = requestedBitrate, + echoCanceler = settings.audioEchoCanceller, + noiseSuppressor = settings.audioNoiseSuppressor, + isStereo = true + ) + + else -> AudioSource.Params.DEFAULT.copy( + bitrate = requestedBitrate, + isStereo = requestedStereo, + echoCanceler = settings.audioEchoCanceller, + noiseSuppressor = settings.audioNoiseSuppressor + ) + } + + prepare( + enableMic = enableMic, + enableDeviceAudio = enableDeviceAudio, + dispatcher = Dispatchers.IO, + audioParams = paramsFromSettings, + audioSource = MediaRecorder.AudioSource.DEFAULT, + mediaProjection = mediaProjection, + ) + + setMute(settings.muteMic, settings.muteDeviceAudio) + setVolume(settings.volumeMic, settings.volumeDeviceAudio) + start() + } + } + override fun handleMessage(msg: Message): Boolean = runBlocking(Dispatchers.Unconfined) { val event: RtspEvent = msg.obj as RtspEvent try { @@ -858,6 +955,106 @@ internal class RtspStreamingService( } } + InternalEvent.StartMicrophoneAudioOnlyStream -> { + if (!settingsLoaded || initializedMode == null) { + XLog.i(getLog("StartMicrophoneAudioOnlyStream", "Settings are not initialized yet. Ignoring.")) + return + } + if (projectionState.pendingStartAttemptId != null) { + XLog.i( + getLog( + "StartMicrophoneAudioOnlyStream", + "Permission already pending id=${projectionState.pendingStartAttemptId ?: "none"}" + ) + ) + return + } + if (projectionState.active != null) { + XLog.d(getLog("StartMicrophoneAudioOnlyStream", "Already streaming. Ignoring.")) + return + } + + val mode = initializedMode ?: rtspSettings.data.value.mode + val settings = rtspSettings.data.value + val validMode = mode == RtspSettings.Values.Mode.SERVER && + settings.streamAudioOnly && + settings.enableMic && + settings.enableDeviceAudio.not() + val blockedByError = currentError != null && currentError !is RtspError.ClientError + val serverReady = serverController?.isActive == true + val audioReady = selectedAudioEncoderInfo != null + if (validMode.not() || blockedByError || serverReady.not() || audioReady.not()) { + XLog.i( + getLog( + "StartMicrophoneAudioOnlyStream", + "Not ready. mode=$mode validMode=$validMode blockedByError=$blockedByError serverReady=$serverReady audioReady=$audioReady" + ) + ) + return + } + + sessionAnalyticsTracker.onStartAttempt( + entryPoint = EntryPoint.BUTTON, + usedCachedPermission = false, + permissionEducationShown = false + ) + + val audioPermissionGranted = + ContextCompat.checkSelfPermission(service, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED + if (!audioPermissionGranted) { + coroutineScope.launch { rtspSettings.updateData { copy(enableMic = false) } } + sessionAnalyticsTracker.onStartFailed(StartFailGroup.PERMISSION_DENIED) + currentError = RtspError.UnknownError(IllegalStateException("Audio-only stream requires audio recording permission")) + return + } + + startForegroundForMicrophoneOnly()?.let { foregroundError -> + sessionAnalyticsTracker.onStartFailed(foregroundError.toForegroundStartFailGroup()) + currentError = foregroundError as? RtspError ?: RtspError.UnknownError(foregroundError) + return + } + + var audioEncoder: AudioEncoder? = null + runCatching { + MasterClock.reset() + MasterClock.ensureStarted() + projectionState.lastVideoParams = null + projectionState.lastAudioParams = null + val setAudioParams: (AudioParams?) -> Unit = { audio -> serverController?.setAudioParams(audio) } + val onFrame: (MediaFrame) -> Unit = { frame -> serverController?.onFrame(frame) ?: frame.release() } + + audioEncoder = createAndStartAudioEncoder( + settings = settings, + enableMic = true, + enableDeviceAudio = false, + mediaProjection = null, + setAudioParams = setAudioParams, + onFrame = onFrame + ) + projectionState.active = ActiveProjection( + mediaProjection = null, + virtualDisplay = null, + videoEncoder = null, + captureSurface = null, + audioEncoder = audioEncoder, + deviceConfiguration = Configuration(service.resources.configuration) + ) + resizeActor?.close() + resizeActor = null + serverController?.start(coroutineScope) + currentError = null + sessionAnalyticsTracker.onStarted(currentActiveConsumersCount()) + XLog.i(getLog("StartMicrophoneAudioOnlyStream", "Started. mode=$mode audioMode=mic")) + }.onFailure { cause -> + XLog.e(getLog("StartMicrophoneAudioOnlyStream", "Failed: ${cause.message}"), cause) + audioEncoder?.stop() + sessionAnalyticsTracker.onStartFailed(StartFailGroup.FATAL) + stopStream(stopServer = true, stopReason = "StartMicrophoneAudioOnlyFailed") + currentError = cause as? RtspError ?: cause.toAudioPipelineError() + serverController?.isActive = false + } + } + is InternalEvent.StartStream -> { if (!settingsLoaded || initializedMode == null) { XLog.i(getLog("StartStream", "Settings are not initialized yet. Ignoring.")) @@ -872,12 +1069,24 @@ internal class RtspStreamingService( return } val mode = initializedMode ?: rtspSettings.data.value.mode - val audioEnabled = rtspSettings.data.value.enableMic || rtspSettings.data.value.enableDeviceAudio + val settings = rtspSettings.data.value + val serverAudioOnly = mode == RtspSettings.Values.Mode.SERVER && settings.streamAudioOnly + val audioEnabled = settings.enableMic || settings.enableDeviceAudio val blockedByError = currentError != null && currentError !is RtspError.ClientError val notReady = blockedByError || when (mode) { - RtspSettings.Values.Mode.SERVER -> serverController?.isActive != true + RtspSettings.Values.Mode.SERVER -> { + val serverReady = serverController?.isActive == true + val videoReady = serverAudioOnly || selectedVideoEncoderInfo != null + val audioReady = if (serverAudioOnly) { + audioEnabled && selectedAudioEncoderInfo != null + } else { + audioEnabled.not() || selectedAudioEncoderInfo != null + } + (serverReady && videoReady && audioReady).not() + } + RtspSettings.Values.Mode.CLIENT -> clientController == null || selectedVideoEncoderInfo == null || (audioEnabled && selectedAudioEncoderInfo == null) } @@ -957,14 +1166,21 @@ internal class RtspStreamingService( XLog.d(getLog("StartProjection", "Already streaming. Ignoring.")) return } - if (selectedVideoEncoderInfo == null) { + val settings = rtspSettings.data.value + val streamAudioOnly = settings.mode == RtspSettings.Values.Mode.SERVER && settings.streamAudioOnly + val audioEnabled = settings.enableMic || settings.enableDeviceAudio + + if (streamAudioOnly && !audioEnabled) { clearPreparedProjectionStartIfNeeded(event.foregroundStartProcessed, event.foregroundStartError) projectionState.pendingStartAttemptId = null - throw IllegalStateException("No video encoder selected") + throw IllegalStateException("Audio-only stream requires microphone or internal audio") } - val settings = rtspSettings.data.value - val audioEnabled = settings.enableMic || settings.enableDeviceAudio + if (!streamAudioOnly && selectedVideoEncoderInfo == null) { + clearPreparedProjectionStartIfNeeded(event.foregroundStartProcessed, event.foregroundStartError) + projectionState.pendingStartAttemptId = null + throw IllegalStateException("No video encoder selected") + } if (audioEnabled && selectedAudioEncoderInfo == null) { clearPreparedProjectionStartIfNeeded(event.foregroundStartProcessed, event.foregroundStartError) @@ -1043,6 +1259,11 @@ internal class RtspStreamingService( } val wantsMicrophoneForSession = audioPermissionGranted && settings.enableMic val wantsDeviceAudioForSession = audioPermissionGranted && settings.enableDeviceAudio + if (streamAudioOnly && !wantsMicrophoneForSession && !wantsDeviceAudioForSession) { + clearPreparedProjectionStartIfNeeded(event.foregroundStartProcessed, event.foregroundStartError) + projectionState.pendingStartAttemptId = null + throw IllegalStateException("Audio-only stream requires audio recording permission") + } // Playback capture also records audio and shares the same audio FGS path on Android 14+. val wantsAudioForegroundService = wantsMicrophoneForSession || wantsDeviceAudioForSession val audioMode = when { @@ -1066,66 +1287,76 @@ internal class RtspStreamingService( var virtualDisplay: VirtualDisplay? = null var captureSurface: Surface? = null val deviceConfiguration = Configuration(service.resources.configuration) - val videoEncoderInfo = selectedVideoEncoderInfo!! - val videoCapabilities = videoEncoderInfo.capabilities.videoCapabilities!! - val bounds = WindowMetricsCalculator.getOrCreate().computeMaximumWindowMetrics(service).bounds - val sourceWidth = bounds.width() - val sourceHeight = bounds.height() - val (_, encodedWidth, encodedHeight) = videoCapabilities.adjustResizeFactor( - sourceWidth, sourceHeight, settings.videoResizeFactor / 100 - ) - - val videoEncoder = VideoEncoder( - codecInfo = videoEncoderInfo, - onVideoInfo = { sps, pps, vps -> - val params = VideoParams(videoEncoderInfo.codec, sps, pps, vps) - projectionState.lastVideoParams = params - setVideoParams(params) - }, - onVideoFrame = onFrame, - onFps = { sendEvent(InternalEvent.OnVideoFps(it)) }, - onError = { - XLog.w(getLog("VideoEncoder.onError", it.message), it) - sendEvent(InternalEvent.Error(it.toVideoPipelineError())) - } - ).apply { - prepare( - encodedWidth, - encodedHeight, - fps = settings.videoFps.coerceIn(videoCapabilities.supportedFrameRates.toClosedRange()), - bitRate = settings.videoBitrateBits.coerceIn(videoCapabilities.bitrateRange.toClosedRange()) + var encodedWidth = 0 + var encodedHeight = 0 + var videoEncoder: VideoEncoder? = null + + if (!streamAudioOnly) { + val videoEncoderInfo = selectedVideoEncoderInfo!! + val videoCapabilities = videoEncoderInfo.capabilities.videoCapabilities!! + val bounds = WindowMetricsCalculator.getOrCreate().computeMaximumWindowMetrics(service).bounds + val sourceWidth = bounds.width() + val sourceHeight = bounds.height() + val adjustedSize = videoCapabilities.adjustResizeFactor( + sourceWidth, sourceHeight, settings.videoResizeFactor / 100 ) - if (!isStartupStillValid()) { - XLog.i(getLog("StartProjection", "Startup invalidated before virtual display creation.")) - stop() - mediaProjection.unregisterCallback(projectionCallback) - return@startProjection false - } - - this.inputSurfaceTexture?.let { surfaceTexture -> - captureSurface = Surface(surfaceTexture) - virtualDisplay = mediaProjection.createVirtualDisplay( - "ScreenStreamVirtualDisplay", + encodedWidth = adjustedSize.second + encodedHeight = adjustedSize.third + + videoEncoder = VideoEncoder( + codecInfo = videoEncoderInfo, + onVideoInfo = { sps, pps, vps -> + val params = VideoParams(videoEncoderInfo.codec, sps, pps, vps) + projectionState.lastVideoParams = params + setVideoParams(params) + }, + onVideoFrame = onFrame, + onFps = { sendEvent(InternalEvent.OnVideoFps(it)) }, + onError = { + XLog.w(getLog("VideoEncoder.onError", it.message), it) + sendEvent(InternalEvent.Error(it.toVideoPipelineError())) + } + ).apply { + prepare( encodedWidth, encodedHeight, - service.resources.displayMetrics.densityDpi, - DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, - captureSurface, - null, - null + fps = settings.videoFps.coerceIn(videoCapabilities.supportedFrameRates.toClosedRange()), + bitRate = settings.videoBitrateBits.coerceIn(videoCapabilities.bitrateRange.toClosedRange()) ) - } + if (!isStartupStillValid()) { + XLog.i(getLog("StartProjection", "Startup invalidated before virtual display creation.")) + stop() + mediaProjection.unregisterCallback(projectionCallback) + return@startProjection false + } - if (virtualDisplay == null || !isStartupStillValid()) { - val reason = if (virtualDisplay == null) "virtualDisplay is null" else "startup invalidated" - XLog.i(getLog("startDisplayCapture", "$reason. Stopping projection.")) - stop() - mediaProjection.unregisterCallback(projectionCallback) - runCatching { captureSurface?.release() } - return@startProjection false - } + this.inputSurfaceTexture?.let { surfaceTexture -> + captureSurface = Surface(surfaceTexture) + virtualDisplay = mediaProjection.createVirtualDisplay( + "ScreenStreamVirtualDisplay", + encodedWidth, + encodedHeight, + service.resources.displayMetrics.densityDpi, + DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, + captureSurface, + null, + null + ) + } - start() + if (virtualDisplay == null || !isStartupStillValid()) { + val reason = if (virtualDisplay == null) "virtualDisplay is null" else "startup invalidated" + XLog.i(getLog("startDisplayCapture", "$reason. Stopping projection.")) + stop() + mediaProjection.unregisterCallback(projectionCallback) + runCatching { captureSurface?.release() } + return@startProjection false + } + + start() + } + } else { + projectionState.lastVideoParams = null } val microphoneEnabledForSession = wantsMicrophoneForSession && audioCaptureAllowed @@ -1133,58 +1364,14 @@ internal class RtspStreamingService( val audioEnabledForSession = microphoneEnabledForSession || deviceAudioEnabledForSession var audioEncoder: AudioEncoder? = null if (audioEnabledForSession) { - val audioEncoderInfo = selectedAudioEncoderInfo!! - audioEncoder = AudioEncoder( - codecInfo = audioEncoderInfo, - onAudioInfo = { params -> - val audioParams = AudioParams(audioEncoderInfo.codec, params.sampleRate, params.isStereo) - projectionState.lastAudioParams = audioParams - setAudioParams(audioParams) - }, - onAudioFrame = onFrame, - onAudioCaptureError = { sendEvent(InternalEvent.AudioCaptureError(it)) }, - onError = { - XLog.w(getLog("AudioEncoder.onError", it.message), it) - sendEvent(InternalEvent.Error(it.toAudioPipelineError())) - } - ).apply { - val requestedBitrate = settings.audioBitrateBits - val requestedStereo = settings.stereoAudio - val paramsFromSettings = when (audioEncoderInfo.codec) { - is Codec.Audio.G711 -> AudioSource.Params.DEFAULT_G711.copy( - bitrate = 64 * 1000, - echoCanceler = settings.audioEchoCanceller, - noiseSuppressor = settings.audioNoiseSuppressor - ) - - is Codec.Audio.OPUS -> AudioSource.Params.DEFAULT_OPUS.copy( - bitrate = requestedBitrate, - echoCanceler = settings.audioEchoCanceller, - noiseSuppressor = settings.audioNoiseSuppressor, - isStereo = true - ) - - else -> AudioSource.Params.DEFAULT.copy( - bitrate = requestedBitrate, - isStereo = requestedStereo, - echoCanceler = settings.audioEchoCanceller, - noiseSuppressor = settings.audioNoiseSuppressor - ) - } - - prepare( - enableMic = microphoneEnabledForSession, - enableDeviceAudio = deviceAudioEnabledForSession, - dispatcher = Dispatchers.IO, - audioParams = paramsFromSettings, - audioSource = MediaRecorder.AudioSource.DEFAULT, - mediaProjection = mediaProjection, - ) - - setMute(settings.muteMic, settings.muteDeviceAudio) - setVolume(settings.volumeMic, settings.volumeDeviceAudio) - start() - } + audioEncoder = createAndStartAudioEncoder( + settings = settings, + enableMic = microphoneEnabledForSession, + enableDeviceAudio = deviceAudioEnabledForSession, + mediaProjection = mediaProjection, + setAudioParams = setAudioParams, + onFrame = onFrame + ) } else { projectionState.lastAudioParams = null setAudioParams(null) @@ -1192,7 +1379,7 @@ internal class RtspStreamingService( if (!isStartupStillValid()) { XLog.i(getLog("StartProjection", "Startup invalidated after encoder startup.")) - videoEncoder.stop() + videoEncoder?.stop() virtualDisplay?.surface = null virtualDisplay?.release() runCatching { captureSurface?.release() } @@ -1201,22 +1388,26 @@ internal class RtspStreamingService( return@startProjection false } + val activeCaptureSurface = captureSurface + if (!streamAudioOnly && activeCaptureSurface == null) { + XLog.i(getLog("StartProjection", "captureSurface is null. Stopping projection.")) + videoEncoder?.stop() + mediaProjection.unregisterCallback(projectionCallback) + return@startProjection false + } + projectionState.active = ActiveProjection( mediaProjection = mediaProjection, - virtualDisplay = virtualDisplay!!, + virtualDisplay = virtualDisplay, videoEncoder = videoEncoder, - captureSurface = captureSurface ?: run { - XLog.i(getLog("StartProjection", "captureSurface is null. Stopping projection.")) - videoEncoder.stop() - mediaProjection.unregisterCallback(projectionCallback) - return@startProjection false - }, + captureSurface = activeCaptureSurface, audioEncoder = audioEncoder, deviceConfiguration = deviceConfiguration, onVideoReconfigureStart = { clientController?.beginVideoReconfigure() } ) resizeActor?.close() - resizeActor = ResizeConflateActor( + resizeActor = if (streamAudioOnly) null + else ResizeConflateActor( projection = projectionState.active!!, initialEncodedWidth = encodedWidth, initialEncodedHeight = encodedHeight @@ -1322,16 +1513,22 @@ internal class RtspStreamingService( projectionState.active?.audioEncoder?.setMute(event.micMute, event.deviceMute) } + InternalEvent.RefreshState -> Unit + is InternalEvent.AudioCaptureError -> { if (audioCaptureDisabled) return audioCaptureDisabled = true + val stopAudioOnlyStream = projectionState.active?.videoEncoder == null projectionState.lastAudioParams = null projectionState.active?.audioEncoder?.stop() projectionState.active?.audioEncoder = null serverController?.setAudioParams(null) clientController?.setAudioParams(null) showAudioCaptureIssueToastOnce() + if (stopAudioOnlyStream) { + stopStream(stopServer = false, stopReason = "AudioCaptureError") + } } is InternalEvent.ConfigurationChange -> { diff --git a/rtsp/src/main/java/info/dvkr/screenstream/rtsp/internal/audio/AudioEncoder.kt b/rtsp/src/main/java/info/dvkr/screenstream/rtsp/internal/audio/AudioEncoder.kt index a717eadb..c29aef28 100644 --- a/rtsp/src/main/java/info/dvkr/screenstream/rtsp/internal/audio/AudioEncoder.kt +++ b/rtsp/src/main/java/info/dvkr/screenstream/rtsp/internal/audio/AudioEncoder.kt @@ -54,7 +54,7 @@ internal class AudioEncoder( dispatcher: CoroutineDispatcher, audioParams: AudioSource.Params, audioSource: Int, - mediaProjection: MediaProjection, + mediaProjection: MediaProjection?, ) { var audioSourceConfigurationError: Throwable? = null runCatching { @@ -71,14 +71,18 @@ internal class AudioEncoder( } this.audioSource = when { - enableMic && enableDeviceAudio -> - MixAudioSource(audioParams, audioSource, mediaProjection, dispatcher, onAudioSourceFrame, onAudioCaptureError) + enableMic && enableDeviceAudio -> { + val projection = requireNotNull(mediaProjection) { "MediaProjection is required for internal audio capture" } + MixAudioSource(audioParams, audioSource, projection, dispatcher, onAudioSourceFrame, onAudioCaptureError) + } enableMic -> MicrophoneSource(audioParams, audioSource, dispatcher, onAudioSourceFrame, onAudioCaptureError) - enableDeviceAudio -> - InternalAudioSource(audioParams, mediaProjection, dispatcher, onAudioSourceFrame, onAudioCaptureError) + enableDeviceAudio -> { + val projection = requireNotNull(mediaProjection) { "MediaProjection is required for internal audio capture" } + InternalAudioSource(audioParams, projection, dispatcher, onAudioSourceFrame, onAudioCaptureError) + } else -> null } diff --git a/rtsp/src/main/java/info/dvkr/screenstream/rtsp/internal/rtsp/RtcpReporter.kt b/rtsp/src/main/java/info/dvkr/screenstream/rtsp/internal/rtsp/RtcpReporter.kt index 57cc6732..f12d50f6 100644 --- a/rtsp/src/main/java/info/dvkr/screenstream/rtsp/internal/rtsp/RtcpReporter.kt +++ b/rtsp/src/main/java/info/dvkr/screenstream/rtsp/internal/rtsp/RtcpReporter.kt @@ -58,6 +58,7 @@ internal class RtcpReporter( private var videoSdes: ByteArray = buildSdesPacket(ssrcVideo) private var audioSdes: ByteArray = buildSdesPacket(ssrcAudio) + private val videoEnabled = ssrcVideo != 0L private val audioEnabled = ssrcAudio != 0L private val lock = Mutex() @@ -68,7 +69,8 @@ internal class RtcpReporter( lock.withLock { if (closed) return@withLock val now = System.currentTimeMillis() - val activeVideo = videoTrack.packetCount > 0 && (now - videoTrack.lastRtpUpdateAtMs) <= (2 * REPORT_INTERVAL_MS + 1000L) + val activeVideo = + videoEnabled && videoTrack.packetCount > 0 && (now - videoTrack.lastRtpUpdateAtMs) <= (2 * REPORT_INTERVAL_MS + 1000L) val activeAudio = audioEnabled && audioTrack.packetCount > 0 && (now - audioTrack.lastRtpUpdateAtMs) <= (2 * REPORT_INTERVAL_MS + 1000L) @@ -106,7 +108,7 @@ internal class RtcpReporter( periodicJob.cancel() - runCatching { sendBye(0, videoTrack.buffer) } + if (videoEnabled) runCatching { sendBye(0, videoTrack.buffer) } if (audioEnabled) runCatching { sendBye(1, audioTrack.buffer) } videoUdpSocket?.close() diff --git a/rtsp/src/main/java/info/dvkr/screenstream/rtsp/internal/rtsp/core/SdpBuilder.kt b/rtsp/src/main/java/info/dvkr/screenstream/rtsp/internal/rtsp/core/SdpBuilder.kt index 297b2e92..cfb455ab 100644 --- a/rtsp/src/main/java/info/dvkr/screenstream/rtsp/internal/rtsp/core/SdpBuilder.kt +++ b/rtsp/src/main/java/info/dvkr/screenstream/rtsp/internal/rtsp/core/SdpBuilder.kt @@ -15,15 +15,17 @@ import kotlin.io.encoding.Base64 internal class SdpBuilder { - fun createSdpBody(videoParams: VideoParams, audioParams: AudioParams?, sdpSessionId: Int): String { - val spsString = videoParams.sps.encodeBase64() - val ppsString = videoParams.pps?.encodeBase64().orEmpty() - val vpsString = videoParams.vps?.encodeBase64().orEmpty() - val videoCodecBody = when (videoParams.codec) { - Codec.Video.H264 -> createH264Body(0, spsString, ppsString) - Codec.Video.H265 -> createH265Body(0, spsString, ppsString, vpsString) - Codec.Video.AV1 -> createAV1Body(0) - } + fun createSdpBody(videoParams: VideoParams?, audioParams: AudioParams?, sdpSessionId: Int): String { + val videoCodecBody = videoParams?.let { params -> + val spsString = params.sps.encodeBase64() + val ppsString = params.pps?.encodeBase64().orEmpty() + val vpsString = params.vps?.encodeBase64().orEmpty() + when (params.codec) { + Codec.Video.H264 -> createH264Body(0, spsString, ppsString) + Codec.Video.H265 -> createH265Body(0, spsString, ppsString, vpsString) + Codec.Video.AV1 -> createAV1Body(0) + } + }.orEmpty() val audioCodecBody = when (audioParams) { null -> "" diff --git a/rtsp/src/main/java/info/dvkr/screenstream/rtsp/internal/rtsp/server/RtspServerConnection.kt b/rtsp/src/main/java/info/dvkr/screenstream/rtsp/internal/rtsp/server/RtspServerConnection.kt index 9f46747b..13eb097a 100644 --- a/rtsp/src/main/java/info/dvkr/screenstream/rtsp/internal/rtsp/server/RtspServerConnection.kt +++ b/rtsp/src/main/java/info/dvkr/screenstream/rtsp/internal/rtsp/server/RtspServerConnection.kt @@ -253,10 +253,11 @@ internal class RtspServerConnection( RtspBaseMessageHandler.Method.DESCRIBE -> tcpStreamSocket.withWriteLock { val videoParams = this@RtspServerConnection.videoParams.get() - if (videoParams == null) { + val audioParams = this@RtspServerConnection.audioParams.get() + if (videoParams == null && audioParams == null) { writeAndFlush(serverMessageHandler.createErrorResponse(503, cSeq)) } else { - writeAndFlush(serverMessageHandler.createDescribeResponse(cSeq, videoParams, audioParams.get())) + writeAndFlush(serverMessageHandler.createDescribeResponse(cSeq, videoParams, audioParams)) } } @@ -265,10 +266,6 @@ internal class RtspServerConnection( tcpStreamSocket.withWriteLock { writeAndFlush(serverMessageHandler.createErrorResponse(455, cSeq)) } continue } - if (this@RtspServerConnection.videoParams.get() == null) { - tcpStreamSocket.withWriteLock { writeAndFlush(serverMessageHandler.createErrorResponse(503, cSeq)) } - continue - } val transportHeader = serverMessageHandler.getTransport(request) val trackId = TRACK_ID_REGEX.find(request)?.groups?.get(1)?.value?.toIntOrNull() ?: -1 serverMessageHandler.getRequestUri(request)?.let { uri -> @@ -278,6 +275,10 @@ internal class RtspServerConnection( tcpStreamSocket.withWriteLock { writeAndFlush(serverMessageHandler.createErrorResponse(400, cSeq)) } continue } + if (trackId == RtpFrame.VIDEO_TRACK_ID && this@RtspServerConnection.videoParams.get() == null) { + tcpStreamSocket.withWriteLock { writeAndFlush(serverMessageHandler.createErrorResponse(404, cSeq)) } + continue + } if (trackId == RtpFrame.AUDIO_TRACK_ID && this@RtspServerConnection.audioParams.get() == null) { tcpStreamSocket.withWriteLock { writeAndFlush(serverMessageHandler.createErrorResponse(404, cSeq)) } continue @@ -437,11 +438,12 @@ internal class RtspServerConnection( tcpStreamSocket.withWriteLock { writeAndFlush(serverMessageHandler.createErrorResponse(454, cSeq)) } continue } - if (this@RtspServerConnection.videoParams.get() == null) { + if (this@RtspServerConnection.videoParams.get() == null && this@RtspServerConnection.audioParams.get() == null) { tcpStreamSocket.withWriteLock { writeAndFlush(serverMessageHandler.createErrorResponse(503, cSeq)) } continue } - if (state != State.Ready || !videoSetupDone) { + val hasSetup = stateLock.withLock { videoSetupDone || audioSetupDone } + if (state != State.Ready || !hasSetup) { tcpStreamSocket.withWriteLock { writeAndFlush(serverMessageHandler.createErrorResponse(455, cSeq)) } continue } diff --git a/rtsp/src/main/java/info/dvkr/screenstream/rtsp/internal/rtsp/server/RtspServerMessageHandler.kt b/rtsp/src/main/java/info/dvkr/screenstream/rtsp/internal/rtsp/server/RtspServerMessageHandler.kt index ce0015ab..727b3591 100644 --- a/rtsp/src/main/java/info/dvkr/screenstream/rtsp/internal/rtsp/server/RtspServerMessageHandler.kt +++ b/rtsp/src/main/java/info/dvkr/screenstream/rtsp/internal/rtsp/server/RtspServerMessageHandler.kt @@ -42,7 +42,7 @@ internal class RtspServerMessageHandler( internal fun createDescribeResponse( cSeq: Int, - videoParams: VideoParams, + videoParams: VideoParams?, audioParams: AudioParams? ): RtspMessage = ResponseBuilder.ok() diff --git a/rtsp/src/main/java/info/dvkr/screenstream/rtsp/settings/RtspSettings.kt b/rtsp/src/main/java/info/dvkr/screenstream/rtsp/settings/RtspSettings.kt index 150d43b2..a6566fea 100644 --- a/rtsp/src/main/java/info/dvkr/screenstream/rtsp/settings/RtspSettings.kt +++ b/rtsp/src/main/java/info/dvkr/screenstream/rtsp/settings/RtspSettings.kt @@ -20,6 +20,7 @@ public interface RtspSettings { public val CLIENT_PROTOCOL: Preferences.Key = stringPreferencesKey("CLIENT_PROTOCOL") public val SERVER_PROTOCOL: Preferences.Key = stringPreferencesKey("SERVER_PROTOCOL") public val MODE: Preferences.Key = stringPreferencesKey("MODE") + public val STREAM_AUDIO_ONLY: Preferences.Key = booleanPreferencesKey("STREAM_AUDIO_ONLY") public val VIDEO_CODEC_AUTO_SELECT: Preferences.Key = booleanPreferencesKey("VIDEO_CODEC_AUTO_SELECT") public val VIDEO_CODEC: Preferences.Key = stringPreferencesKey("VIDEO_CODEC") @@ -58,6 +59,7 @@ public interface RtspSettings { public val CLIENT_PROTOCOL: Values.ProtocolPolicy = Values.ProtocolPolicy.AUTO public val SERVER_PROTOCOL: Values.ProtocolPolicy = Values.ProtocolPolicy.AUTO public val MODE: Values.Mode = Values.Mode.SERVER + public const val STREAM_AUDIO_ONLY: Boolean = false public const val VIDEO_CODEC_AUTO_SELECT: Boolean = true public const val VIDEO_CODEC: String = "" @@ -120,6 +122,7 @@ public interface RtspSettings { public val clientProtocol: Values.ProtocolPolicy = Default.CLIENT_PROTOCOL, public val serverProtocol: Values.ProtocolPolicy = Default.SERVER_PROTOCOL, public val mode: Values.Mode = Default.MODE, + public val streamAudioOnly: Boolean = Default.STREAM_AUDIO_ONLY, public val videoCodecAutoSelect: Boolean = Default.VIDEO_CODEC_AUTO_SELECT, public val videoCodec: String = Default.VIDEO_CODEC, diff --git a/rtsp/src/main/java/info/dvkr/screenstream/rtsp/settings/RtspSettingsImpl.kt b/rtsp/src/main/java/info/dvkr/screenstream/rtsp/settings/RtspSettingsImpl.kt index 4241021a..4faae986 100644 --- a/rtsp/src/main/java/info/dvkr/screenstream/rtsp/settings/RtspSettingsImpl.kt +++ b/rtsp/src/main/java/info/dvkr/screenstream/rtsp/settings/RtspSettingsImpl.kt @@ -71,6 +71,9 @@ internal class RtspSettingsImpl( if (newSettings.mode != RtspSettings.Default.MODE) set(RtspSettings.Key.MODE, newSettings.mode.name) + if (newSettings.streamAudioOnly != RtspSettings.Default.STREAM_AUDIO_ONLY) + set(RtspSettings.Key.STREAM_AUDIO_ONLY, newSettings.streamAudioOnly) + if (newSettings.videoCodecAutoSelect != RtspSettings.Default.VIDEO_CODEC_AUTO_SELECT) set(RtspSettings.Key.VIDEO_CODEC_AUTO_SELECT, newSettings.videoCodecAutoSelect) @@ -161,6 +164,7 @@ internal class RtspSettingsImpl( mode = runCatching { this[RtspSettings.Key.MODE]?.let { name -> RtspSettings.Values.Mode.valueOf(name) } }.getOrNull() ?: RtspSettings.Default.MODE, + streamAudioOnly = this[RtspSettings.Key.STREAM_AUDIO_ONLY] ?: RtspSettings.Default.STREAM_AUDIO_ONLY, videoCodecAutoSelect = this[RtspSettings.Key.VIDEO_CODEC_AUTO_SELECT] ?: RtspSettings.Default.VIDEO_CODEC_AUTO_SELECT, videoCodec = this[RtspSettings.Key.VIDEO_CODEC] ?: RtspSettings.Default.VIDEO_CODEC, diff --git a/rtsp/src/main/java/info/dvkr/screenstream/rtsp/ui/RtspMainScreenUI.kt b/rtsp/src/main/java/info/dvkr/screenstream/rtsp/ui/RtspMainScreenUI.kt index d89d2180..8129a618 100644 --- a/rtsp/src/main/java/info/dvkr/screenstream/rtsp/ui/RtspMainScreenUI.kt +++ b/rtsp/src/main/java/info/dvkr/screenstream/rtsp/ui/RtspMainScreenUI.kt @@ -166,12 +166,18 @@ internal fun RtspMainScreenUI( val mediaServerUrlError = selectedMode == RtspSettings.Values.Mode.CLIENT && runCatching { RtspUrl.parse(settings.serverAddress) }.isFailure + val startWithoutScreenCapture = selectedMode == RtspSettings.Values.Mode.SERVER && + settings.streamAudioOnly && + settings.enableMic && + settings.enableDeviceAudio.not() Button( onClick = dropUnlessStarted { doubleClickProtection.processClick { if (state.isStreaming) { sendEvent(RtspEvent.Intentable.StopStream("User action: Button")) + } else if (startWithoutScreenCapture) { + sendEvent(RtspStreamingService.InternalEvent.StartMicrophoneAudioOnlyStream) } else { screenCaptureStartRequester.request() } diff --git a/rtsp/src/main/java/info/dvkr/screenstream/rtsp/ui/main/cards/ServerSettingsCard.kt b/rtsp/src/main/java/info/dvkr/screenstream/rtsp/ui/main/cards/ServerSettingsCard.kt index d4f91c64..55cc0ae4 100644 --- a/rtsp/src/main/java/info/dvkr/screenstream/rtsp/ui/main/cards/ServerSettingsCard.kt +++ b/rtsp/src/main/java/info/dvkr/screenstream/rtsp/ui/main/cards/ServerSettingsCard.kt @@ -23,6 +23,7 @@ import info.dvkr.screenstream.rtsp.settings.RtspSettings import info.dvkr.screenstream.rtsp.ui.main.settings.common.RtspSettingModal import info.dvkr.screenstream.rtsp.ui.main.settings.server.AddressFilterEditor import info.dvkr.screenstream.rtsp.ui.main.settings.server.AddressFilterRow +import info.dvkr.screenstream.rtsp.ui.main.settings.server.AudioOnlyRow import info.dvkr.screenstream.rtsp.ui.main.settings.server.EnableIPv4Row import info.dvkr.screenstream.rtsp.ui.main.settings.server.EnableIPv6Row import info.dvkr.screenstream.rtsp.ui.main.settings.server.InterfaceFilterEditor @@ -68,6 +69,15 @@ internal fun ServerSettingsCard( HorizontalDivider() + AudioOnlyRow( + enabled = enabled, + streamAudioOnly = settings.streamAudioOnly + ) { newValue -> + updateSettings { copy(streamAudioOnly = newValue) } + } + + HorizontalDivider() + InterfaceFilterRow( enabled = enabled, interfaceFilter = settings.interfaceFilter diff --git a/rtsp/src/main/java/info/dvkr/screenstream/rtsp/ui/main/settings/server/AudioOnly.kt b/rtsp/src/main/java/info/dvkr/screenstream/rtsp/ui/main/settings/server/AudioOnly.kt new file mode 100644 index 00000000..dac62a9d --- /dev/null +++ b/rtsp/src/main/java/info/dvkr/screenstream/rtsp/ui/main/settings/server/AudioOnly.kt @@ -0,0 +1,22 @@ +package info.dvkr.screenstream.rtsp.ui.main.settings.server + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import info.dvkr.screenstream.rtsp.R +import info.dvkr.screenstream.rtsp.ui.main.settings.common.SettingSwitchRow + +@Composable +internal fun AudioOnlyRow( + enabled: Boolean, + streamAudioOnly: Boolean, + onValueChange: (Boolean) -> Unit +) { + SettingSwitchRow( + enabled = enabled, + checked = streamAudioOnly, + iconRes = R.drawable.mobile_speaker_24px, + title = stringResource(id = R.string.rtsp_pref_audio_only), + summary = stringResource(id = R.string.rtsp_pref_audio_only_summary), + onValueChange = onValueChange + ) +} diff --git a/rtsp/src/main/res/values-af/strings.xml b/rtsp/src/main/res/values-af/strings.xml index 7ca1db54..865216d4 100644 --- a/rtsp/src/main/res/values-af/strings.xml +++ b/rtsp/src/main/res/values-af/strings.xml @@ -61,6 +61,7 @@ Publieke IPv4 Publieke IPv6 Loopback + %1$s (%2$s) Video-instellings Video-enkodeerder: @@ -96,6 +97,8 @@ Toemaak RTSP-protokol Kies vervoer vir RTP-pakkette + Slegs klank + Stroom klank sonder video Koppelvlak filter Kies netwerkkoppelvlakke vir bediener Wi-Fi diff --git a/rtsp/src/main/res/values-am/strings.xml b/rtsp/src/main/res/values-am/strings.xml index 524c5443..b00be234 100644 --- a/rtsp/src/main/res/values-am/strings.xml +++ b/rtsp/src/main/res/values-am/strings.xml @@ -61,6 +61,7 @@ ህዝባዊ IPv4 ህዝባዊ IPv6 Loopback + %1$s (%2$s) የቪዲዮ ቅንብሮች የቪዲዮ አርቃቂ: @@ -96,6 +97,8 @@ ዝጋ RTSP ፕሮቶኮል ለRTP ጥቅሎች መጓጓዣ ይምረጡ + ድምጽ ብቻ + ቪዲዮ ሳይኖር ድምጽ ይልቀቁ መገናኛ ማጣሪያ ለአገልጋዩ የአውታረ መረብ መገናኛዎችን ይምረጡ ዋይ-ፋይ diff --git a/rtsp/src/main/res/values-ar/strings.xml b/rtsp/src/main/res/values-ar/strings.xml index 49450568..d09fc9b4 100644 --- a/rtsp/src/main/res/values-ar/strings.xml +++ b/rtsp/src/main/res/values-ar/strings.xml @@ -61,6 +61,7 @@ IPv4 عام IPv6 عام حلقة محلية + %1$s (%2$s) إعدادات الفيديو ترميز الفيديو: @@ -96,6 +97,8 @@ إغلاق بروتوكول RTSP اختر ناقل حزم RTP + الصوت فقط + بث الصوت بدون فيديو تصفية الواجهة اختر واجهات الشبكة للخادم واي فاي diff --git a/rtsp/src/main/res/values-bn/strings.xml b/rtsp/src/main/res/values-bn/strings.xml index 5aa9d081..e7bcefae 100644 --- a/rtsp/src/main/res/values-bn/strings.xml +++ b/rtsp/src/main/res/values-bn/strings.xml @@ -61,6 +61,7 @@ পাবলিক IPv4 পাবলিক IPv6 লুপব্যাক + %1$s (%2$s) ভিডিও সেটিংস ভিডিও এনকোডার: @@ -96,6 +97,8 @@ বন্ধ করুন RTSP প্রোটোকল RTP প্যাকেটের পরিবহন নির্বাচন করুন + শুধু অডিও + ভিডিও ছাড়া অডিও স্ট্রিম করুন ইন্টারফেস ফিল্টার সার্ভারের জন্য নেটওয়ার্ক ইন্টারফেস নির্বাচন করুন ওয়াই-ফাই diff --git a/rtsp/src/main/res/values-de/strings.xml b/rtsp/src/main/res/values-de/strings.xml index 9d47b9e8..b909003f 100644 --- a/rtsp/src/main/res/values-de/strings.xml +++ b/rtsp/src/main/res/values-de/strings.xml @@ -61,6 +61,7 @@ Öffentliche IPv4 Öffentliche IPv6 Loopback + %1$s (%2$s) Videoeinstellungen Video-Encoder: @@ -96,6 +97,8 @@ Schließen RTSP-Protokoll Transport für RTP-Pakete auswählen + Nur Audio + Audio ohne Video streamen Schnittstellenfilter Netzwerkschnittstellen für Server auswählen WLAN diff --git a/rtsp/src/main/res/values-es/strings.xml b/rtsp/src/main/res/values-es/strings.xml index 8bc2b600..aa06a911 100644 --- a/rtsp/src/main/res/values-es/strings.xml +++ b/rtsp/src/main/res/values-es/strings.xml @@ -61,6 +61,7 @@ IPv4 pública IPv6 pública Loopback + %1$s (%2$s) Ajustes de video Codificador de video: @@ -96,6 +97,8 @@ Cerrar Protocolo RTSP Seleccionar transporte para paquetes RTP + Solo audio + Transmitir audio sin video Filtro de interfaz Seleccionar interfaces de red para el servidor Wi-Fi diff --git a/rtsp/src/main/res/values-eu/strings.xml b/rtsp/src/main/res/values-eu/strings.xml index 373840d5..b78fba76 100644 --- a/rtsp/src/main/res/values-eu/strings.xml +++ b/rtsp/src/main/res/values-eu/strings.xml @@ -61,6 +61,7 @@ IPv4 publikoa IPv6 publikoa Loopback + %1$s (%2$s) Bideo-ezarpenak Bideo kodetzailea: @@ -96,6 +97,8 @@ Itxi RTSP protokoloa Hautatu RTP paketeetarako garraioa + Audioa soilik + Streamatu audioa bideorik gabe Interfaze iragazkia Hautatu sare interfazeak zerbitzarirako Wi-Fi diff --git a/rtsp/src/main/res/values-fr/strings.xml b/rtsp/src/main/res/values-fr/strings.xml index a3fe9afc..60c21ff7 100644 --- a/rtsp/src/main/res/values-fr/strings.xml +++ b/rtsp/src/main/res/values-fr/strings.xml @@ -61,6 +61,7 @@ IPv4 public IPv6 public Loopback + %1$s (%2$s) Paramètres vidéo Encodeur vidéo : @@ -96,6 +97,8 @@ Fermer Protocole RTSP Sélectionner le transport des paquets RTP + Audio uniquement + Diffuser audio sans vidéo Filtre d\'interface Sélectionner les interfaces réseau pour le serveur Wi-Fi diff --git a/rtsp/src/main/res/values-hi/strings.xml b/rtsp/src/main/res/values-hi/strings.xml index 08b24f1d..367fb06d 100644 --- a/rtsp/src/main/res/values-hi/strings.xml +++ b/rtsp/src/main/res/values-hi/strings.xml @@ -61,6 +61,7 @@ सार्वजनिक IPv4 सार्वजनिक IPv6 लूपबैक + %1$s (%2$s) वीडियो सेटिंग्स वीडियो एनकोडर: @@ -96,6 +97,8 @@ बंद करें RTSP प्रोटोकॉल RTP पैकेट के लिए ट्रांसपोर्ट चुनें + केवल ऑडियो + वीडियो के बिना ऑडियो स्ट्रीम करें इंटरफ़ेस फ़िल्टर सर्वर के लिए नेटवर्क इंटरफ़ेस चुनें वाई-फ़ाई diff --git a/rtsp/src/main/res/values-in/strings.xml b/rtsp/src/main/res/values-in/strings.xml index ce25c11b..3a2c5482 100644 --- a/rtsp/src/main/res/values-in/strings.xml +++ b/rtsp/src/main/res/values-in/strings.xml @@ -61,6 +61,7 @@ IPv4 publik IPv6 publik Loopback + %1$s (%2$s) Setelan video Encoder video: @@ -96,6 +97,8 @@ Tutup Protokol RTSP Pilih transport untuk paket RTP + Audio saja + Streaming audio tanpa video Filter antarmuka Pilih antarmuka jaringan untuk server Wi-Fi diff --git a/rtsp/src/main/res/values-it/strings.xml b/rtsp/src/main/res/values-it/strings.xml index 5569ef86..72aafc13 100644 --- a/rtsp/src/main/res/values-it/strings.xml +++ b/rtsp/src/main/res/values-it/strings.xml @@ -61,6 +61,7 @@ IPv4 pubblico IPv6 pubblico Loopback + %1$s (%2$s) Impostazioni video Encoder video: @@ -96,6 +97,8 @@ Chiudi Protocollo RTSP Seleziona trasporto per pacchetti RTP + Solo audio + Trasmetti audio senza video Filtro interfaccia Seleziona interfacce di rete per il server Wi-Fi diff --git a/rtsp/src/main/res/values-ja/strings.xml b/rtsp/src/main/res/values-ja/strings.xml index 1c497379..9d918594 100644 --- a/rtsp/src/main/res/values-ja/strings.xml +++ b/rtsp/src/main/res/values-ja/strings.xml @@ -61,6 +61,7 @@ パブリックIPv4 パブリックIPv6 ループバック + %1$s (%2$s) 動画設定 動画エンコーダー: @@ -96,6 +97,8 @@ 閉じる RTSPプロトコル RTPパケットの転送方式を選択 + 音声のみ + 動画なしで音声を配信 インターフェースフィルター サーバー用のネットワークインターフェースを選択 Wi-Fi diff --git a/rtsp/src/main/res/values-jv/strings.xml b/rtsp/src/main/res/values-jv/strings.xml index ca82376d..e34ba933 100644 --- a/rtsp/src/main/res/values-jv/strings.xml +++ b/rtsp/src/main/res/values-jv/strings.xml @@ -61,6 +61,7 @@ IPv4 umum IPv6 umum Loopback + %1$s (%2$s) Setelan video Encoder video: @@ -96,6 +97,8 @@ Nutup Protokol RTSP Pilih transport kanggo paket RTP + Mung audio + Stream audio tanpa video Panyaring antarmuka Pilih antarmuka jaringan kanggo server Wi-Fi diff --git a/rtsp/src/main/res/values-ka/strings.xml b/rtsp/src/main/res/values-ka/strings.xml index 2ac9f6c9..301b2db3 100644 --- a/rtsp/src/main/res/values-ka/strings.xml +++ b/rtsp/src/main/res/values-ka/strings.xml @@ -61,6 +61,7 @@ საჯარო IPv4 საჯარო IPv6 Loopback + %1$s (%2$s) ვიდეოს პარამეტრები ვიდეო ენკოდერი: @@ -96,6 +97,8 @@ დახურვა RTSP პროტოკოლი აირჩიეთ RTP პაკეტების ტრანსპორტი + მხოლოდ აუდიო + აუდიოს სტრიმი ვიდეოს გარეშე ინტერფეისის ფილტრი აირჩიეთ ქსელის ინტერფეისები სერვერისთვის Wi-Fi diff --git a/rtsp/src/main/res/values-nl/strings.xml b/rtsp/src/main/res/values-nl/strings.xml index a75b4493..f7a84f62 100644 --- a/rtsp/src/main/res/values-nl/strings.xml +++ b/rtsp/src/main/res/values-nl/strings.xml @@ -61,6 +61,7 @@ Publiek IPv4 Publiek IPv6 Loopback + %1$s (%2$s) Video-instellingen Video-encoder: @@ -96,6 +97,8 @@ Sluiten RTSP-protocol Selecteer transport voor RTP-pakketten + Alleen audio + Stream audio zonder video Interfacefilter Selecteer netwerkinterfaces voor server Wi-Fi diff --git a/rtsp/src/main/res/values-pl/strings.xml b/rtsp/src/main/res/values-pl/strings.xml index 1917c9e5..d13191ce 100644 --- a/rtsp/src/main/res/values-pl/strings.xml +++ b/rtsp/src/main/res/values-pl/strings.xml @@ -61,6 +61,7 @@ Publiczny IPv4 Publiczny IPv6 Loopback + %1$s (%2$s) Ustawienia wideo Koder wideo: @@ -96,6 +97,8 @@ Zamknij Protokół RTSP Wybierz transport dla pakietów RTP + Tylko audio + Strumieniuj audio bez wideo Filtr interfejsu Wybierz interfejsy sieciowe dla serwera Wi-Fi diff --git a/rtsp/src/main/res/values-pt/strings.xml b/rtsp/src/main/res/values-pt/strings.xml index 9c066338..e89dabd5 100644 --- a/rtsp/src/main/res/values-pt/strings.xml +++ b/rtsp/src/main/res/values-pt/strings.xml @@ -61,6 +61,7 @@ IPv4 público IPv6 público Loopback + %1$s (%2$s) Configurações de vídeo Codificador de vídeo: @@ -96,6 +97,8 @@ Fechar Protocolo RTSP Selecionar transporte para pacotes RTP + Somente áudio + Transmitir áudio sem vídeo Filtro de interface Selecionar interfaces de rede para servidor Wi-Fi diff --git a/rtsp/src/main/res/values-ru/strings.xml b/rtsp/src/main/res/values-ru/strings.xml index 149768d0..184c5a01 100644 --- a/rtsp/src/main/res/values-ru/strings.xml +++ b/rtsp/src/main/res/values-ru/strings.xml @@ -61,6 +61,7 @@ Публичный IPv4 Публичный IPv6 Loopback + %1$s (%2$s) Настройки видео Видеокодек: @@ -96,6 +97,8 @@ Закрыть Протокол RTSP Выберите транспорт для RTP-пакетов + Только аудио + Передавать аудио без видео Фильтр интерфейсов Выберите сетевые интерфейсы для сервера Wi-Fi diff --git a/rtsp/src/main/res/values-tr/strings.xml b/rtsp/src/main/res/values-tr/strings.xml index 0383df60..84581b3d 100644 --- a/rtsp/src/main/res/values-tr/strings.xml +++ b/rtsp/src/main/res/values-tr/strings.xml @@ -61,6 +61,7 @@ Genel IPv4 Genel IPv6 Loopback + %1$s (%2$s) Video ayarları Video kodlayıcı: @@ -96,6 +97,8 @@ Kapat RTSP protokolü RTP paketleri için taşıma seçin + Yalnızca ses + Video olmadan ses yayınla Arayüz filtresi Sunucu için ağ arayüzlerini seçin Wi-Fi diff --git a/rtsp/src/main/res/values-uk/strings.xml b/rtsp/src/main/res/values-uk/strings.xml index e7916d96..eeb212ef 100644 --- a/rtsp/src/main/res/values-uk/strings.xml +++ b/rtsp/src/main/res/values-uk/strings.xml @@ -61,6 +61,7 @@ Публічний IPv4 Публічний IPv6 Loopback + %1$s (%2$s) Налаштування відео Відеокодек: @@ -96,6 +97,8 @@ Закрити Протокол RTSP Виберіть транспорт для RTP-пакетів + Лише аудіо + Транслювати аудіо без відео Фільтр інтерфейсів Виберіть мережеві інтерфейси для сервера Wi-Fi diff --git a/rtsp/src/main/res/values-ur/strings.xml b/rtsp/src/main/res/values-ur/strings.xml index e3ad263a..3b5e70c1 100644 --- a/rtsp/src/main/res/values-ur/strings.xml +++ b/rtsp/src/main/res/values-ur/strings.xml @@ -61,6 +61,7 @@ عوامی IPv4 عوامی IPv6 لوپ بیک + %1$s (%2$s) ویڈیو ترتیبات ویڈیو انکوڈر: @@ -96,6 +97,8 @@ بند کریں RTSP پروٹوکول RTP پیکٹس کیلئے ٹرانسپورٹ منتخب کریں + صرف آڈیو + ویڈیو کے بغیر آڈیو اسٹریم کریں انٹرفیس فلٹر سرور کیلئے نیٹ ورک انٹرفیس منتخب کریں وائی فائی diff --git a/rtsp/src/main/res/values-uz/strings.xml b/rtsp/src/main/res/values-uz/strings.xml index c2358d51..fbf7dad1 100644 --- a/rtsp/src/main/res/values-uz/strings.xml +++ b/rtsp/src/main/res/values-uz/strings.xml @@ -61,6 +61,7 @@ Ommaviy IPv4 Ommaviy IPv6 Loopback + %1$s (%2$s) Video sozlamalari Video kodlovchi: @@ -96,6 +97,8 @@ Yopish RTSP protokoli RTP paketlari uchun transportni tanlang + Faqat audio + Videosiz audio uzating Interfeys filtri Server uchun tarmoq interfeyslarini tanlang Wi-Fi diff --git a/rtsp/src/main/res/values-zh-rTW/strings.xml b/rtsp/src/main/res/values-zh-rTW/strings.xml index 13f3c2dc..3e71d43a 100644 --- a/rtsp/src/main/res/values-zh-rTW/strings.xml +++ b/rtsp/src/main/res/values-zh-rTW/strings.xml @@ -61,6 +61,7 @@ 公用IPv4 公用IPv6 迴路 + %1$s (%2$s) 視訊設定 視訊編碼器: @@ -96,6 +97,8 @@ 關閉 RTSP 通訊協定 選擇RTP封包傳輸方式 + 僅音訊 + 無視訊,僅串流音訊 介面篩選器 選擇伺服器的網路介面 Wi-Fi diff --git a/rtsp/src/main/res/values-zh/strings.xml b/rtsp/src/main/res/values-zh/strings.xml index 8393fd17..a69b2876 100644 --- a/rtsp/src/main/res/values-zh/strings.xml +++ b/rtsp/src/main/res/values-zh/strings.xml @@ -61,6 +61,7 @@ 公网IPv4 公网IPv6 环回 + %1$s (%2$s) 视频设置 视频编码器: @@ -96,6 +97,8 @@ 关闭 RTSP协议 选择RTP数据包传输方式 + 仅音频 + 无视频,仅传输音频 接口过滤器 选择服务器的网络接口 Wi-Fi diff --git a/rtsp/src/main/res/values/strings.xml b/rtsp/src/main/res/values/strings.xml index 7ebd88e0..d5469676 100644 --- a/rtsp/src/main/res/values/strings.xml +++ b/rtsp/src/main/res/values/strings.xml @@ -97,6 +97,8 @@ Close RTSP protocol Select transport for RTP packets + Audio only + Stream audio without video Interface filter Select network interfaces for server Wi-Fi