feat: native recording
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -39,3 +39,5 @@ google-services.json
|
||||
.kotlin/
|
||||
|
||||
scrcpy.help*
|
||||
|
||||
*.mp4
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
|
||||
- 改进: 设备页连接设备后选项卡显示当前配置名
|
||||
- 修复: 正确实现 `--disable-screensaver`,`FLAG_KEEP_SCREEN_ON` 而非 `PARTIAL_WAKE_LOCK`
|
||||
- 新增: `--record`, `--record-format`
|
||||
- 重构: adb / scrcpy 连接断开逻辑、状态管理
|
||||
- 修复: 更新不再会重置自定义增加的 Scrcpy 参数配置档
|
||||
- 新增: `--new-display` 快捷填入本机数值
|
||||
|
||||
## 0.2.1
|
||||
|
||||
|
||||
11
README.md
11
README.md
@@ -56,12 +56,15 @@
|
||||
|
||||
## NOT-TODO
|
||||
|
||||
~~应该有东西的但我不记得要写什么了~~
|
||||
|
||||
- 低版本安卓适配
|
||||
- 项目的 `minSdk` 为 26 / Android 8,98.4% 的设备都能成功安装上,只是出问题不管
|
||||
- 更多的文件操作,包括不限于 `删除`, `重命名` 等
|
||||
- 可以长按复制路径去终端用 `rm`, `mv`
|
||||
- 录制的进一步优化
|
||||
- `MediaCodec` 限制太大,再继续做要引入 `ffmpeg`,ofc no way
|
||||
- ADB 安装应用 / adb install
|
||||
- 需要大改 JNI 的实现因此不做
|
||||
- 可以推送文件之后使用终端安装或手动控制安装
|
||||
- 应用图标
|
||||
- 我做不来
|
||||
- 有线控制 / fastboot
|
||||
@@ -92,6 +95,10 @@
|
||||
3. 码率只能拉到 40Mbps 嫌低
|
||||
- 每个 Slider 选项的标题都可以点开自己输入值
|
||||
|
||||
4. 录制/下载的文件在哪
|
||||
- /sdcard/Movies/Scrcpy/
|
||||
- /sdcard/Download/Scrcpy/
|
||||
|
||||
## 构建
|
||||
|
||||
- JDK 17+
|
||||
|
||||
4
TODO.md
4
TODO.md
@@ -6,9 +6,7 @@
|
||||
|
||||
### LOWER PRIORITY
|
||||
|
||||
- orientation locking
|
||||
- `-r, --record=file.mp4` 投屏时录制到文件 Record screen to file. The format is determined by the --record-format option if set, or by the file extension.
|
||||
- `--record-format` 录制格式 Force recording format (mp4, mkv, m4a, mka, opus, aac, flac or wav).
|
||||
- ~~orientation locking~~
|
||||
|
||||
## FEATURES
|
||||
|
||||
|
||||
@@ -54,8 +54,8 @@ android {
|
||||
applicationId = "io.github.miuzarte.scrcpyforandroid"
|
||||
minSdk = 26
|
||||
targetSdk = 37
|
||||
versionCode = 17
|
||||
versionName = "0.2.1"
|
||||
versionCode = 18
|
||||
versionName = "0.2.2"
|
||||
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
|
||||
@@ -44,8 +44,6 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import io.github.miuzarte.scrcpyforandroid.constants.ThemeModes
|
||||
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
|
||||
import io.github.miuzarte.scrcpyforandroid.haptics.LocalAppHaptics
|
||||
import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
|
||||
import io.github.miuzarte.scrcpyforandroid.password.BiometricGate
|
||||
import io.github.miuzarte.scrcpyforandroid.password.PasswordCreatedState
|
||||
import io.github.miuzarte.scrcpyforandroid.password.PasswordEntry
|
||||
@@ -117,15 +115,12 @@ class LockscreenPasswordActivity : FragmentActivity() {
|
||||
}
|
||||
val themeController =
|
||||
remember(themeMode) { ThemeController(colorSchemeMode = themeMode) }
|
||||
val haptics = rememberAppHaptics()
|
||||
|
||||
MiuixTheme(
|
||||
controller = themeController,
|
||||
smoothRounding = asBundle.smoothCorner,
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalSnackbarController provides snackbarController,
|
||||
LocalAppHaptics provides haptics,
|
||||
) {
|
||||
LockscreenPasswordScreen(
|
||||
activity = this,
|
||||
|
||||
@@ -30,6 +30,8 @@ object NativeCoreFacade {
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val bootstrapLock = Any()
|
||||
private val bootstrapPackets = ArrayDeque<CachedPacket>()
|
||||
@Volatile
|
||||
private var recordingSurfaceAttached = false
|
||||
|
||||
@Volatile
|
||||
private var latestConfigPacket: CachedPacket? = null
|
||||
@@ -109,6 +111,39 @@ object NativeCoreFacade {
|
||||
Log.i(TAG, "detachVideoSurface(): releasing decoder with destroyed surface")
|
||||
decoder?.release()
|
||||
decoder = null
|
||||
} else if (activeSurfaceId == null && !recordingSurfaceAttached) {
|
||||
decoder?.release()
|
||||
decoder = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun attachRecordingSurface(
|
||||
surface: Surface,
|
||||
width: Int,
|
||||
height: Int,
|
||||
onFrameRendered: ((Long) -> Unit)? = null,
|
||||
) {
|
||||
sessionLifecycleMutex.withLock {
|
||||
recordingSurfaceAttached = true
|
||||
renderer.attachRecordSurface(surface, width, height, onFrameRendered)
|
||||
val session = currentSessionInfo
|
||||
if (session != null && decoder == null) {
|
||||
createOrReplaceDecoder(session)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun detachRecordingSurface(
|
||||
surface: Surface? = null,
|
||||
releaseSurface: Boolean = false,
|
||||
) {
|
||||
sessionLifecycleMutex.withLock {
|
||||
recordingSurfaceAttached = false
|
||||
renderer.detachRecordSurface(surface, releaseSurface)
|
||||
if (activeSurfaceId == null) {
|
||||
decoder?.release()
|
||||
decoder = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,7 +167,7 @@ object NativeCoreFacade {
|
||||
bootstrapPackets.clear()
|
||||
latestConfigPacket = null
|
||||
}
|
||||
if (activeSurfaceId != null) {
|
||||
if (activeSurfaceId != null || recordingSurfaceAttached) {
|
||||
Log.i(TAG, "onScrcpySessionStarted(): bind decoder to persistent surface")
|
||||
createOrReplaceDecoder(session)
|
||||
}
|
||||
@@ -170,6 +205,7 @@ object NativeCoreFacade {
|
||||
latestConfigPacket = null
|
||||
}
|
||||
currentSessionInfo = null
|
||||
recordingSurfaceAttached = false
|
||||
}
|
||||
|
||||
private const val TAG = "NativeCoreFacade"
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
package io.github.miuzarte.scrcpyforandroid.haptics
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
|
||||
@Composable
|
||||
fun performHaptics(type: HapticFeedbackType): Unit {
|
||||
LocalHapticFeedback.current.performHapticFeedback(type)
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class AppHaptics(
|
||||
val confirm: () -> Unit,
|
||||
val contextClick: () -> Unit,
|
||||
val gestureEnd: () -> Unit,
|
||||
val gestureThresholdActivate: () -> Unit,
|
||||
val keyboardTap: () -> Unit,
|
||||
val longPress: () -> Unit,
|
||||
val reject: () -> Unit,
|
||||
val segmentFrequentTick: () -> Unit,
|
||||
val segmentTick: () -> Unit,
|
||||
val textHandleMove: () -> Unit,
|
||||
val toggleOff: () -> Unit,
|
||||
val toggleOn: () -> Unit,
|
||||
val virtualKey: () -> Unit,
|
||||
)
|
||||
|
||||
val LocalAppHaptics = staticCompositionLocalOf<AppHaptics> {
|
||||
error("No AppHaptics provided")
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberAppHaptics(): AppHaptics {
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
|
||||
val performConfirm = rememberUpdatedState {
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.Confirm)
|
||||
}
|
||||
val performContextClick = rememberUpdatedState {
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.ContextClick)
|
||||
}
|
||||
val performGestureEnd = rememberUpdatedState {
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureEnd)
|
||||
}
|
||||
val performGestureThresholdActivate = rememberUpdatedState {
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate)
|
||||
}
|
||||
val performKeyboardTan = rememberUpdatedState {
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.KeyboardTap)
|
||||
}
|
||||
val performLongPress = rememberUpdatedState {
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
}
|
||||
val performReject = rememberUpdatedState {
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.Reject)
|
||||
}
|
||||
val performSegmentFrequentTick = rememberUpdatedState {
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
|
||||
}
|
||||
val performSegmentTick = rememberUpdatedState {
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentTick)
|
||||
}
|
||||
val performTextHandleMove = rememberUpdatedState {
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
}
|
||||
val performToggleOff = rememberUpdatedState {
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.ToggleOff)
|
||||
}
|
||||
val performToggleOn = rememberUpdatedState {
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.ToggleOn)
|
||||
}
|
||||
val performVirtualKey = rememberUpdatedState {
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.VirtualKey)
|
||||
}
|
||||
|
||||
return remember {
|
||||
AppHaptics(
|
||||
confirm = { performConfirm.value.invoke() },
|
||||
contextClick = { performContextClick.value.invoke() },
|
||||
gestureEnd = { performGestureEnd.value.invoke() },
|
||||
gestureThresholdActivate = { performGestureThresholdActivate.value.invoke() },
|
||||
keyboardTap = { performKeyboardTan.value.invoke() },
|
||||
longPress = { performLongPress.value.invoke() },
|
||||
reject = { performReject.value.invoke() },
|
||||
segmentFrequentTick = { performSegmentFrequentTick.value.invoke() },
|
||||
segmentTick = { performSegmentTick.value.invoke() },
|
||||
textHandleMove = { performTextHandleMove.value.invoke() },
|
||||
toggleOff = { performToggleOff.value.invoke() },
|
||||
toggleOn = { performToggleOn.value.invoke() },
|
||||
virtualKey = { performVirtualKey.value.invoke() },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package io.github.miuzarte.scrcpyforandroid.nativecore
|
||||
|
||||
import android.graphics.SurfaceTexture
|
||||
import android.opengl.EGLExt
|
||||
import android.opengl.EGL14
|
||||
import android.opengl.EGLConfig
|
||||
import android.opengl.EGLContext
|
||||
@@ -37,6 +38,12 @@ class PersistentVideoRenderer {
|
||||
private var displayEglSurface: EGLSurface = EGL14.EGL_NO_SURFACE
|
||||
private var displaySurface: Surface? = null
|
||||
private var displaySurfaceId: Int? = null
|
||||
private var recordEglSurface: EGLSurface = EGL14.EGL_NO_SURFACE
|
||||
private var recordSurface: Surface? = null
|
||||
private var recordSurfaceId: Int? = null
|
||||
private var recordWidth: Int = 0
|
||||
private var recordHeight: Int = 0
|
||||
private var onRecordFrameRendered: ((Long) -> Unit)? = null
|
||||
|
||||
private var oesTextureId = 0
|
||||
private var decoderSurfaceTexture: SurfaceTexture? = null
|
||||
@@ -101,11 +108,64 @@ class PersistentVideoRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
fun attachRecordSurface(
|
||||
surface: Surface,
|
||||
width: Int,
|
||||
height: Int,
|
||||
onFrameRendered: ((Long) -> Unit)? = null,
|
||||
) {
|
||||
ensureInitialized()
|
||||
val newId = System.identityHashCode(surface)
|
||||
if (recordSurfaceId == newId &&
|
||||
recordWidth == width &&
|
||||
recordHeight == height
|
||||
) {
|
||||
onRecordFrameRendered = onFrameRendered
|
||||
return
|
||||
}
|
||||
Log.i(tag, "attachRecordSurface(): request surfaceId=$newId size=${width}x$height")
|
||||
handler.post {
|
||||
if (released || !surface.isValid) return@post
|
||||
releaseRecordSurfaceLocked()
|
||||
recordSurface = surface
|
||||
recordSurfaceId = newId
|
||||
recordWidth = width.coerceAtLeast(1)
|
||||
recordHeight = height.coerceAtLeast(1)
|
||||
onRecordFrameRendered = onFrameRendered
|
||||
recordEglSurface = EGL14.eglCreateWindowSurface(
|
||||
eglDisplay,
|
||||
eglConfig,
|
||||
surface,
|
||||
intArrayOf(EGL14.EGL_NONE),
|
||||
0
|
||||
)
|
||||
Log.i(tag, "attachRecordSurface(): attached surfaceId=$newId")
|
||||
drawFrame()
|
||||
}
|
||||
}
|
||||
|
||||
fun detachRecordSurface(surface: Surface? = null, releaseSurface: Boolean = false) {
|
||||
val requestId = surface?.let { System.identityHashCode(it) }
|
||||
Log.i(
|
||||
tag,
|
||||
"detachRecordSurface(): request surfaceId=$requestId releaseSurface=$releaseSurface current=$recordSurfaceId"
|
||||
)
|
||||
handler.post {
|
||||
if (released) return@post
|
||||
if (requestId != null && requestId != recordSurfaceId) return@post
|
||||
releaseRecordSurfaceLocked()
|
||||
if (releaseSurface) {
|
||||
runCatching { surface?.release() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun release() {
|
||||
handler.post {
|
||||
if (released) return@post
|
||||
released = true
|
||||
releaseDisplaySurfaceLocked()
|
||||
releaseRecordSurfaceLocked()
|
||||
runCatching { decoderSurface?.release() }
|
||||
decoderSurface = null
|
||||
runCatching { decoderSurfaceTexture?.release() }
|
||||
@@ -236,17 +296,73 @@ class PersistentVideoRenderer {
|
||||
}
|
||||
.onFailure { Log.w(tag, "updateTexImage failed", it) }
|
||||
surfaceTexture.getTransformMatrix(stMatrix)
|
||||
val frameTimestampNs = surfaceTexture.timestamp
|
||||
|
||||
if (displayEglSurface == EGL14.EGL_NO_SURFACE) {
|
||||
return
|
||||
if (recordEglSurface != EGL14.EGL_NO_SURFACE) {
|
||||
renderToSurface(
|
||||
eglSurface = recordEglSurface,
|
||||
width = recordWidth,
|
||||
height = recordHeight,
|
||||
presentationTimeNs = frameTimestampNs,
|
||||
)
|
||||
onRecordFrameRendered?.invoke(frameTimestampNs)
|
||||
}
|
||||
|
||||
val width = IntArray(1)
|
||||
val height = IntArray(1)
|
||||
EGL14.eglQuerySurface(eglDisplay, displayEglSurface, EGL14.EGL_WIDTH, width, 0)
|
||||
EGL14.eglQuerySurface(eglDisplay, displayEglSurface, EGL14.EGL_HEIGHT, height, 0)
|
||||
EGL14.eglMakeCurrent(eglDisplay, displayEglSurface, displayEglSurface, eglContext)
|
||||
GLES20.glViewport(0, 0, width[0].coerceAtLeast(1), height[0].coerceAtLeast(1))
|
||||
if (displayEglSurface != EGL14.EGL_NO_SURFACE) {
|
||||
val width = IntArray(1)
|
||||
val height = IntArray(1)
|
||||
EGL14.eglQuerySurface(eglDisplay, displayEglSurface, EGL14.EGL_WIDTH, width, 0)
|
||||
EGL14.eglQuerySurface(eglDisplay, displayEglSurface, EGL14.EGL_HEIGHT, height, 0)
|
||||
renderToSurface(
|
||||
eglSurface = displayEglSurface,
|
||||
width = width[0].coerceAtLeast(1),
|
||||
height = height[0].coerceAtLeast(1),
|
||||
)
|
||||
val rendered = frameRenderedCount.incrementAndGet()
|
||||
if (rendered == 1L || rendered % 120L == 0L) {
|
||||
Log.i(
|
||||
tag,
|
||||
"drawFrame(): rendered=$rendered consumed=${frameConsumedCount.get()} available=${frameAvailableCount.get()} viewport=${width[0]}x${height[0]}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun releaseDisplaySurfaceLocked() {
|
||||
if (displayEglSurface != EGL14.EGL_NO_SURFACE) {
|
||||
EGL14.eglDestroySurface(eglDisplay, displayEglSurface)
|
||||
displayEglSurface = EGL14.EGL_NO_SURFACE
|
||||
}
|
||||
if (displaySurfaceId != null) {
|
||||
Log.i(tag, "releaseDisplaySurfaceLocked(): surfaceId=$displaySurfaceId")
|
||||
}
|
||||
displaySurface = null
|
||||
displaySurfaceId = null
|
||||
}
|
||||
|
||||
private fun releaseRecordSurfaceLocked() {
|
||||
if (recordEglSurface != EGL14.EGL_NO_SURFACE) {
|
||||
EGL14.eglDestroySurface(eglDisplay, recordEglSurface)
|
||||
recordEglSurface = EGL14.EGL_NO_SURFACE
|
||||
}
|
||||
if (recordSurfaceId != null) {
|
||||
Log.i(tag, "releaseRecordSurfaceLocked(): surfaceId=$recordSurfaceId")
|
||||
}
|
||||
recordSurface = null
|
||||
recordSurfaceId = null
|
||||
recordWidth = 0
|
||||
recordHeight = 0
|
||||
onRecordFrameRendered = null
|
||||
}
|
||||
|
||||
private fun renderToSurface(
|
||||
eglSurface: EGLSurface,
|
||||
width: Int,
|
||||
height: Int,
|
||||
presentationTimeNs: Long? = null,
|
||||
) {
|
||||
EGL14.eglMakeCurrent(eglDisplay, eglSurface, eglSurface, eglContext)
|
||||
GLES20.glViewport(0, 0, width.coerceAtLeast(1), height.coerceAtLeast(1))
|
||||
GLES20.glClearColor(0f, 0f, 0f, 1f)
|
||||
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
|
||||
GLES20.glUseProgram(program)
|
||||
@@ -264,26 +380,8 @@ class PersistentVideoRenderer {
|
||||
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
|
||||
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, oesTextureId)
|
||||
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
|
||||
EGL14.eglSwapBuffers(eglDisplay, displayEglSurface)
|
||||
val rendered = frameRenderedCount.incrementAndGet()
|
||||
if (rendered == 1L || rendered % 120L == 0L) {
|
||||
Log.i(
|
||||
tag,
|
||||
"drawFrame(): rendered=$rendered consumed=${frameConsumedCount.get()} available=${frameAvailableCount.get()} viewport=${width[0]}x${height[0]}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun releaseDisplaySurfaceLocked() {
|
||||
if (displayEglSurface != EGL14.EGL_NO_SURFACE) {
|
||||
EGL14.eglDestroySurface(eglDisplay, displayEglSurface)
|
||||
displayEglSurface = EGL14.EGL_NO_SURFACE
|
||||
}
|
||||
if (displaySurfaceId != null) {
|
||||
Log.i(tag, "releaseDisplaySurfaceLocked(): surfaceId=$displaySurfaceId")
|
||||
}
|
||||
displaySurface = null
|
||||
displaySurfaceId = null
|
||||
presentationTimeNs?.let { EGLExt.eglPresentationTimeANDROID(eglDisplay, eglSurface, it) }
|
||||
EGL14.eglSwapBuffers(eglDisplay, eglSurface)
|
||||
}
|
||||
|
||||
private fun createExternalTexture(): Int {
|
||||
|
||||
@@ -6,7 +6,7 @@ import androidx.activity.compose.LocalActivity
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.MoreVert
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -25,24 +25,27 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import io.github.miuzarte.scrcpyforandroid.StreamActivity
|
||||
import io.github.miuzarte.scrcpyforandroid.haptics.LocalAppHaptics
|
||||
import io.github.miuzarte.scrcpyforandroid.ui.contextClick
|
||||
import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget
|
||||
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut
|
||||
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts
|
||||
import io.github.miuzarte.scrcpyforandroid.password.PasswordPickerPopupContent
|
||||
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
|
||||
import io.github.miuzarte.scrcpyforandroid.scaffolds.SectionSmallTitle
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
||||
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
|
||||
import io.github.miuzarte.scrcpyforandroid.services.AppScreenOn
|
||||
import io.github.miuzarte.scrcpyforandroid.services.DeviceAdbBackgroundRunner
|
||||
import io.github.miuzarte.scrcpyforandroid.services.ConnectionController
|
||||
import io.github.miuzarte.scrcpyforandroid.services.ConnectionStateStore
|
||||
import io.github.miuzarte.scrcpyforandroid.services.DeviceAdbAutoReconnectManager
|
||||
import io.github.miuzarte.scrcpyforandroid.services.DeviceAdbConnectionCoordinator
|
||||
import io.github.miuzarte.scrcpyforandroid.services.DeviceAdbSessionState
|
||||
import io.github.miuzarte.scrcpyforandroid.services.DisconnectCause
|
||||
import io.github.miuzarte.scrcpyforandroid.services.EventLogger
|
||||
import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent
|
||||
import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
|
||||
@@ -97,10 +100,18 @@ private const val ADB_TCP_PROBE_TIMEOUT_MS = 500
|
||||
private const val PREVIEW_CARD_ITEM_KEY = "preview_card"
|
||||
private const val PREVIEW_CARD_ITEM_INDEX = 3
|
||||
|
||||
internal data class DeviceConnectionServices(
|
||||
val adbCoordinator: DeviceAdbConnectionCoordinator,
|
||||
val connectionStateStore: ConnectionStateStore,
|
||||
val connectionController: ConnectionController,
|
||||
val autoReconnectManager: DeviceAdbAutoReconnectManager,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun DeviceTabScreen(
|
||||
internal fun DeviceTabScreen(
|
||||
scrollBehavior: ScrollBehavior,
|
||||
scrcpy: Scrcpy,
|
||||
connectionServices: DeviceConnectionServices,
|
||||
bottomInnerPadding: Dp,
|
||||
onOpenReorderDevices: () -> Unit,
|
||||
) {
|
||||
@@ -155,6 +166,7 @@ fun DeviceTabScreen(
|
||||
contentPadding = pagePadding,
|
||||
scrollBehavior = scrollBehavior,
|
||||
scrcpy = scrcpy,
|
||||
connectionServices = connectionServices,
|
||||
bottomInnerPadding = bottomInnerPadding,
|
||||
)
|
||||
}
|
||||
@@ -162,10 +174,11 @@ fun DeviceTabScreen(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DeviceTabPage(
|
||||
internal fun DeviceTabPage(
|
||||
contentPadding: PaddingValues,
|
||||
scrollBehavior: ScrollBehavior,
|
||||
scrcpy: Scrcpy,
|
||||
connectionServices: DeviceConnectionServices,
|
||||
bottomInnerPadding: Dp,
|
||||
) {
|
||||
val activity = LocalActivity.current
|
||||
@@ -173,11 +186,13 @@ fun DeviceTabPage(
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val taskScope = remember { CoroutineScope(Dispatchers.IO + SupervisorJob()) }
|
||||
val adbCoordinator = remember { DeviceAdbConnectionCoordinator() }
|
||||
val adbBackgroundRunner = remember { DeviceAdbBackgroundRunner() }
|
||||
val adbCoordinator = connectionServices.adbCoordinator
|
||||
val connectionStateStore = connectionServices.connectionStateStore
|
||||
val connectionController = connectionServices.connectionController
|
||||
val autoReconnectManager = connectionServices.autoReconnectManager
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
val haptics = LocalAppHaptics.current
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val navigator = LocalRootNavigator.current
|
||||
val snackbar = LocalSnackbarController.current
|
||||
|
||||
@@ -233,16 +248,6 @@ fun DeviceTabPage(
|
||||
|
||||
var isAppInForeground by rememberSaveable { mutableStateOf(true) }
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
AppScreenOn.release()
|
||||
}
|
||||
}
|
||||
DisposableEffect(adbBackgroundRunner) {
|
||||
onDispose {
|
||||
adbBackgroundRunner.close()
|
||||
}
|
||||
}
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
isAppInForeground =
|
||||
lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
|
||||
@@ -263,7 +268,8 @@ fun DeviceTabPage(
|
||||
// Try to avoid blocking UI/recomposition and keeps adb call ordering deterministic.
|
||||
|
||||
var busy by rememberSaveable { mutableStateOf(false) }
|
||||
var adbSession by rememberSaveable { mutableStateOf(DeviceAdbSessionState()) }
|
||||
val connectionState by connectionStateStore.state.collectAsState()
|
||||
val adbSession = connectionState.adbSession
|
||||
val sessionInfo by scrcpy.currentSessionState.collectAsState()
|
||||
val listingsRefreshBusy by scrcpy.listings.refreshBusyState.collectAsState()
|
||||
val listingsRefreshVersion by scrcpy.listings.refreshVersionState.collectAsState()
|
||||
@@ -274,7 +280,9 @@ fun DeviceTabPage(
|
||||
var showRecentTasksSheet by rememberSaveable { mutableStateOf(false) }
|
||||
var showAllAppsSheet by rememberSaveable { mutableStateOf(false) }
|
||||
var imeRequestToken by rememberSaveable { mutableIntStateOf(0) }
|
||||
val listState = rememberLazyListState()
|
||||
val listState = rememberSaveable(saver = LazyListState.Saver) {
|
||||
LazyListState()
|
||||
}
|
||||
val isPreviewCardVisible by remember(listState) {
|
||||
derivedStateOf {
|
||||
listState.layoutInfo.visibleItemsInfo.any { it.key == PREVIEW_CARD_ITEM_KEY }
|
||||
@@ -317,6 +325,7 @@ fun DeviceTabPage(
|
||||
adbSession.connectedScrcpyProfileId
|
||||
|
||||
val connectedScrcpyBundle = resolveScrcpyBundle(connectedScrcpyProfileId)
|
||||
val connectedVideoPlaybackEnabled = connectedScrcpyBundle.video && connectedScrcpyBundle.videoPlayback
|
||||
val connectedScrcpyProfileName = remember(connectedScrcpyProfileId, scrcpyProfilesState) {
|
||||
scrcpyProfilesState.profiles
|
||||
.firstOrNull { it.id == connectedScrcpyProfileId }
|
||||
@@ -387,7 +396,11 @@ fun DeviceTabPage(
|
||||
}
|
||||
|
||||
suspend fun commitImeText(text: String) {
|
||||
submitImeText(scrcpy, text) { error, useClipboardPaste ->
|
||||
submitImeText(
|
||||
scrcpy = scrcpy,
|
||||
text = text,
|
||||
keyInjectMode = scrcpyOptions.toClientOptions(connectedScrcpyBundle).keyInjectMode,
|
||||
) { error, useClipboardPaste ->
|
||||
logEvent("输入法文本提交失败: ${error.message}", Log.WARN, error)
|
||||
snackbar.show(
|
||||
if (useClipboardPaste) "非 ASCII 文本粘贴失败"
|
||||
@@ -426,14 +439,16 @@ fun DeviceTabPage(
|
||||
clearQuickOnlineForTarget: ConnectionTarget? = currentTarget,
|
||||
logMessage: String? = null,
|
||||
showSnackMessage: String? = null,
|
||||
cause: DisconnectCause = DisconnectCause.User,
|
||||
statusLine: String = "未连接",
|
||||
) {
|
||||
// Also stops scrcpy.
|
||||
runCatching { scrcpy.stop() }
|
||||
runCatching { adbCoordinator.disconnect() }
|
||||
AppScreenOn.release()
|
||||
adbSession = DeviceAdbSessionState()
|
||||
AppRuntime.currentConnectionTarget = null
|
||||
clearQuickOnlineForTarget?.let { target ->
|
||||
val result = connectionController.disconnectAdbConnection(
|
||||
clearQuickOnlineForTarget = clearQuickOnlineForTarget,
|
||||
cause = cause,
|
||||
statusLine = statusLine,
|
||||
)
|
||||
val targetToClear = result.clearedTarget
|
||||
targetToClear?.let { target ->
|
||||
if (target.host.isNotBlank())
|
||||
savedShortcuts = savedShortcuts.update(
|
||||
host = target.host, port = target.port
|
||||
@@ -446,19 +461,24 @@ fun DeviceTabPage(
|
||||
}
|
||||
|
||||
suspend fun disconnectCurrentTargetBeforeConnecting(newHost: String, newPort: Int) {
|
||||
// Force old target cleanup before switching to another endpoint.
|
||||
val current = currentTarget
|
||||
if (!adbConnected || current == null) return
|
||||
if (current.host == newHost && current.port == newPort) return
|
||||
val disconnected = connectionController.disconnectCurrentTargetBeforeConnecting(
|
||||
newHost = newHost,
|
||||
newPort = newPort,
|
||||
) ?: return
|
||||
|
||||
sessionReconnectBlacklistHosts += current.host
|
||||
disconnectAdbConnection(clearQuickOnlineForTarget = current)
|
||||
sessionReconnectBlacklistHosts += disconnected.host
|
||||
if (disconnected.host.isNotBlank()) {
|
||||
savedShortcuts = savedShortcuts.update(
|
||||
host = disconnected.host,
|
||||
port = disconnected.port,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun applyConnectedDeviceCapabilities(sdkInt: Int, release: String) {
|
||||
val currentBundle = resolveScrcpyBundle(connectedScrcpyProfileId)
|
||||
val audioSupported = sdkInt !in 0..<30
|
||||
adbSession = adbSession.copy(audioForwardingSupported = audioSupported)
|
||||
connectionController.applyConnectedDeviceCapabilities(sdkInt)
|
||||
if (!audioSupported && currentBundle.audio) {
|
||||
scope.launch {
|
||||
if (connectedScrcpyProfileId == ScrcpyOptions.GLOBAL_PROFILE_ID) {
|
||||
@@ -476,7 +496,6 @@ fun DeviceTabPage(
|
||||
)
|
||||
}
|
||||
val cameraSupported = sdkInt !in 0..<31
|
||||
adbSession = adbSession.copy(cameraMirroringSupported = cameraSupported)
|
||||
if (!cameraSupported && currentBundle.videoSource == "camera") {
|
||||
scope.launch {
|
||||
if (connectedScrcpyProfileId == ScrcpyOptions.GLOBAL_PROFILE_ID) {
|
||||
@@ -508,35 +527,7 @@ fun DeviceTabPage(
|
||||
* indefinitely. Use a small, caller-chosen timeout to keep UX snappy.
|
||||
*/
|
||||
suspend fun connectWithTimeout(host: String, port: Int) {
|
||||
adbCoordinator.connectWithTimeout(host, port, ADB_CONNECT_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that the current ADB connection is still alive.
|
||||
*
|
||||
* Behavior:
|
||||
* - Runs on [adbWorkerDispatcher] with a short timeout.
|
||||
* - First checks `nativeCore.adbIsConnected()` to avoid unnecessary shell calls.
|
||||
* - Executes a lightweight `adb shell` command (`echo -n 1`) to verify the remote side is
|
||||
* responsive. Returns true only when both checks succeed.
|
||||
*
|
||||
* Notes for reliability:
|
||||
* - Some devices may accept TCP connections but have a hung adb-server process; the shell
|
||||
* echo check helps detect that state.
|
||||
*/
|
||||
suspend fun keepAliveCheck(host: String, port: Int): Boolean {
|
||||
return adbCoordinator.isConnected(ADB_KEEPALIVE_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Quickly test TCP reachability to an endpoint.
|
||||
*
|
||||
* - Uses a plain Socket connect on [Dispatchers.IO] with a very short timeout.
|
||||
* - This is useful before attempting an adb connect to avoid long native timeouts.
|
||||
* - Returns true when TCP handshake succeeds within [ADB_TCP_PROBE_TIMEOUT_MS].
|
||||
*/
|
||||
suspend fun probeTcpReachable(host: String, port: Int): Boolean {
|
||||
return adbCoordinator.probeTcpReachable(host, port, ADB_TCP_PROBE_TIMEOUT_MS)
|
||||
connectionController.connectWithTimeout(host, port, ADB_CONNECT_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -551,7 +542,7 @@ fun DeviceTabPage(
|
||||
fun runBusy(label: String, onFinished: (() -> Unit)? = null, block: suspend () -> Unit) {
|
||||
// For non-adb actions (start/stop/pair/list refresh...).
|
||||
if (busy) return
|
||||
taskScope.launch {
|
||||
scope.launch {
|
||||
busy = true
|
||||
try {
|
||||
block()
|
||||
@@ -591,7 +582,7 @@ fun DeviceTabPage(
|
||||
) {
|
||||
// For manual adb operations from user actions.
|
||||
if (adbConnecting) return
|
||||
taskScope.launch {
|
||||
scope.launch {
|
||||
onStarted?.invoke()
|
||||
adbConnecting = true
|
||||
try {
|
||||
@@ -612,36 +603,22 @@ fun DeviceTabPage(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun runAutoAdbConnect(host: String, port: Int): Boolean {
|
||||
return runCatching {
|
||||
connectWithTimeout(host, port)
|
||||
true
|
||||
}.getOrElse { error ->
|
||||
val detail = error.message?.takeIf { it.isNotBlank() } ?: error.javaClass.simpleName
|
||||
logEvent("自动重连失败: $host:$port ($detail)", Log.WARN)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(adbConnected, currentTarget, isAppInForeground) {
|
||||
if (!adbConnected || currentTarget == null) return@LaunchedEffect
|
||||
adbBackgroundRunner.runKeepAliveLoop(
|
||||
sessionState = { adbSession },
|
||||
autoReconnectManager.runKeepAliveLoop(
|
||||
isForeground = { isAppInForeground },
|
||||
intervalMs = ADB_KEEPALIVE_INTERVAL_MS,
|
||||
keepAliveCheck = ::keepAliveCheck,
|
||||
reconnect = ::connectWithTimeout,
|
||||
connectTimeoutMs = ADB_CONNECT_TIMEOUT_MS,
|
||||
keepAliveTimeoutMs = ADB_KEEPALIVE_TIMEOUT_MS,
|
||||
onReconnectSuccess = { host, port ->
|
||||
logEvent("ADB 自动重连成功: $host:$port")
|
||||
adbSession = adbSession.copy(
|
||||
isConnected = true,
|
||||
statusLine = "$host:$port",
|
||||
)
|
||||
snackbar.show("ADB 自动重连成功")
|
||||
},
|
||||
onReconnectFailure = { error ->
|
||||
disconnectAdbConnection()
|
||||
adbSession = adbSession.copy(statusLine = "ADB 连接断开")
|
||||
disconnectAdbConnection(
|
||||
cause = DisconnectCause.KeepAliveFailed,
|
||||
statusLine = "ADB 连接断开",
|
||||
)
|
||||
logEvent("ADB 自动重连失败: $error", Log.ERROR)
|
||||
snackbar.show("ADB 自动重连失败")
|
||||
},
|
||||
@@ -656,7 +633,7 @@ fun DeviceTabPage(
|
||||
?.scrcpyProfileId
|
||||
?: ScrcpyOptions.GLOBAL_PROFILE_ID
|
||||
if (boundProfileId != adbSession.connectedScrcpyProfileId) {
|
||||
adbSession = adbSession.copy(connectedScrcpyProfileId = boundProfileId)
|
||||
connectionController.syncConnectedScrcpyProfileId(boundProfileId)
|
||||
logEvent("当前连接设备已切换为配置: $boundProfileId")
|
||||
}
|
||||
}
|
||||
@@ -672,7 +649,7 @@ fun DeviceTabPage(
|
||||
?.let { options.copy(startApp = it) }
|
||||
?: options
|
||||
val session = scrcpy.start(resolvedOptions)
|
||||
pendingScrollToPreview = true
|
||||
pendingScrollToPreview = resolvedOptions.video && resolvedOptions.videoPlayback
|
||||
if (resolvedOptions.startApp.isNotBlank() && resolvedOptions.control) {
|
||||
runCatching {
|
||||
scrcpy.startApp(resolvedOptions.startApp)
|
||||
@@ -687,13 +664,16 @@ fun DeviceTabPage(
|
||||
)
|
||||
}
|
||||
}
|
||||
if (resolvedOptions.fullscreen || openFullscreen) withContext(Dispatchers.Main) {
|
||||
if ((resolvedOptions.fullscreen || openFullscreen) &&
|
||||
resolvedOptions.video &&
|
||||
resolvedOptions.videoPlayback
|
||||
) withContext(Dispatchers.Main) {
|
||||
context.startActivity(StreamActivity.createIntent(context))
|
||||
}
|
||||
if (resolvedOptions.disableScreensaver)
|
||||
AppScreenOn.acquire()
|
||||
|
||||
adbSession = adbSession.copy(statusLine = "scrcpy 运行中")
|
||||
connectionController.markScrcpyStarted()
|
||||
@SuppressLint("DefaultLocale")
|
||||
val videoDetail =
|
||||
if (!resolvedOptions.video) "off"
|
||||
@@ -712,25 +692,31 @@ fun DeviceTabPage(
|
||||
", control=${resolvedOptions.control}, turnScreenOff=${resolvedOptions.turnScreenOff}" +
|
||||
", maxSize=${resolvedOptions.maxSize}, maxFps=${resolvedOptions.maxFps}"
|
||||
)
|
||||
snackbar.show("scrcpy 已启动")
|
||||
snackbar.show(
|
||||
"scrcpy 已启动" +
|
||||
if (resolvedOptions.recordFilename.isNotBlank()) "并开始录制"
|
||||
else ""
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun stopScrcpySession() {
|
||||
val activeBundle = resolveScrcpyBundle(connectedScrcpyProfileId)
|
||||
val options = scrcpyOptions.toClientOptions(activeBundle).fix()
|
||||
scrcpy.stop()
|
||||
if (options.killAdbOnClose) {
|
||||
currentTarget?.host?.let { sessionReconnectBlacklistHosts += it }
|
||||
disconnectAdbConnection(
|
||||
clearQuickOnlineForTarget = currentTarget,
|
||||
logMessage = "scrcpy 已停止,ADB 已断开",
|
||||
showSnackMessage = "scrcpy 已停止,ADB 已断开",
|
||||
)
|
||||
val stopResult = connectionController.stopScrcpySession(killAdbOnClose = true)
|
||||
stopResult.clearedTarget?.let { target ->
|
||||
if (target.host.isNotBlank()) {
|
||||
savedShortcuts = savedShortcuts.update(
|
||||
host = target.host,
|
||||
port = target.port,
|
||||
)
|
||||
}
|
||||
}
|
||||
logEvent("scrcpy 已停止,ADB 已断开")
|
||||
snackbar.show("scrcpy 已停止,ADB 已断开")
|
||||
} else {
|
||||
AppScreenOn.release()
|
||||
adbSession = adbSession.copy(
|
||||
statusLine = "${currentTarget!!.host}:${currentTarget.port}"
|
||||
)
|
||||
connectionController.stopScrcpySession(killAdbOnClose = false)
|
||||
logEvent("scrcpy 已停止")
|
||||
snackbar.show("scrcpy 已停止")
|
||||
}
|
||||
@@ -748,23 +734,19 @@ fun DeviceTabPage(
|
||||
autoEnterFullScreen: Boolean = false,
|
||||
scrcpyProfileId: String = ScrcpyOptions.GLOBAL_PROFILE_ID,
|
||||
) {
|
||||
val target = ConnectionTarget(host, port)
|
||||
adbSession = adbSession.copy(
|
||||
isConnected = true,
|
||||
currentTarget = target,
|
||||
connectedScrcpyProfileId = scrcpyProfileId,
|
||||
statusLine = "$host:$port",
|
||||
val connected = connectionController.handleAdbConnected(
|
||||
host = host,
|
||||
port = port,
|
||||
scrcpyProfileId = scrcpyProfileId,
|
||||
)
|
||||
AppRuntime.currentConnectionTarget = target
|
||||
|
||||
val info = adbCoordinator.fetchConnectedDeviceInfo(host, port)
|
||||
val target = connected.target
|
||||
val info = connected.info
|
||||
val fullLabel = if (info.serial.isNotBlank()) {
|
||||
"${info.model} (${info.serial})"
|
||||
} else {
|
||||
info.model
|
||||
}
|
||||
|
||||
adbSession = adbSession.copy(connectedDeviceLabel = info.model)
|
||||
applyConnectedDeviceCapabilities(info.sdkInt, info.androidRelease)
|
||||
savedShortcuts = savedShortcuts.update(
|
||||
host = host, port = port,
|
||||
@@ -821,8 +803,7 @@ fun DeviceTabPage(
|
||||
isAppInForeground,
|
||||
) {
|
||||
if (adbConnected || !asBundle.adbAutoReconnectPairedDevice) return@LaunchedEffect
|
||||
adbBackgroundRunner.runAutoReconnectLoop(
|
||||
isConnected = { false },
|
||||
autoReconnectManager.runAutoReconnectLoop(
|
||||
isForeground = { isAppInForeground },
|
||||
isAutoReconnectEnabled = { asBundle.adbAutoReconnectPairedDevice },
|
||||
isBusy = { busy },
|
||||
@@ -830,7 +811,8 @@ fun DeviceTabPage(
|
||||
hasActiveSession = { sessionInfo != null },
|
||||
savedShortcuts = { savedShortcuts.toList() },
|
||||
isBlacklisted = { host -> sessionReconnectBlacklistHosts.contains(host) },
|
||||
probeTcpReachable = ::probeTcpReachable,
|
||||
connectTimeoutMs = ADB_CONNECT_TIMEOUT_MS,
|
||||
probeTimeoutMs = ADB_TCP_PROBE_TIMEOUT_MS,
|
||||
discoverConnectService = {
|
||||
adbCoordinator.discoverConnectService(
|
||||
timeoutMs = ADB_AUTO_RECONNECT_DISCOVER_TIMEOUT_MS,
|
||||
@@ -845,36 +827,16 @@ fun DeviceTabPage(
|
||||
)
|
||||
logEvent("mDNS 发现新端口,已更新快速设备: $host:$oldPort -> $host:$newPort")
|
||||
},
|
||||
connectKnownShortcut = { target ->
|
||||
if (!runAutoAdbConnect(target.host, target.port)) {
|
||||
false
|
||||
} else {
|
||||
savedShortcuts = savedShortcuts.update(
|
||||
host = target.host,
|
||||
port = target.port,
|
||||
)
|
||||
handleAdbConnected(
|
||||
target.host,
|
||||
target.port,
|
||||
scrcpyProfileId = target.scrcpyProfileId,
|
||||
)
|
||||
logEvent("ADB 快速探测连接成功: ${target.host}:${target.port}")
|
||||
true
|
||||
}
|
||||
onKnownDeviceReconnected = { target ->
|
||||
savedShortcuts = savedShortcuts.update(
|
||||
host = target.host,
|
||||
port = target.port,
|
||||
)
|
||||
logEvent("ADB 快速探测连接成功: ${target.host}:${target.port}")
|
||||
},
|
||||
connectDiscoveredShortcut = { host, port, knownDevice ->
|
||||
if (!runAutoAdbConnect(host, port)) {
|
||||
false
|
||||
} else {
|
||||
savedShortcuts = savedShortcuts.update(host = host, port = port)
|
||||
handleAdbConnected(
|
||||
host,
|
||||
port,
|
||||
scrcpyProfileId = knownDevice.scrcpyProfileId,
|
||||
)
|
||||
logEvent("ADB 自动重连成功: $host:$port")
|
||||
true
|
||||
}
|
||||
onDiscoveredDeviceReconnected = { host, port, _ ->
|
||||
savedShortcuts = savedShortcuts.update(host = host, port = port)
|
||||
logEvent("ADB 自动重连成功: $host:$port")
|
||||
},
|
||||
retryIntervalMs = ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS,
|
||||
)
|
||||
@@ -927,12 +889,12 @@ fun DeviceTabPage(
|
||||
adbConnected && currentTarget?.host == host && currentTarget.port == port
|
||||
},
|
||||
onConnectionFailed = { error ->
|
||||
adbSession = adbSession.copy(statusLine = "ADB 连接失败")
|
||||
connectionController.markConnectionFailed(error)
|
||||
logEvent("ADB 连接失败: $error", Log.ERROR)
|
||||
snackbar.show("ADB 连接失败")
|
||||
},
|
||||
onQuickConnectedChanged = { quickConnected ->
|
||||
adbSession = adbSession.copy(isQuickConnected = quickConnected)
|
||||
connectionController.updateQuickConnected(quickConnected)
|
||||
},
|
||||
onBlackListHost = { host -> sessionReconnectBlacklistHosts += host },
|
||||
setActiveDeviceActionId = { activeDeviceActionId = it },
|
||||
@@ -986,7 +948,7 @@ fun DeviceTabPage(
|
||||
}
|
||||
},
|
||||
onAction = { device ->
|
||||
haptics.contextClick()
|
||||
haptic.contextClick()
|
||||
if (editingDeviceId == device.id) editingDeviceId = null
|
||||
adbCallbacks.onDeviceAction(device)
|
||||
},
|
||||
@@ -1103,7 +1065,7 @@ fun DeviceTabPage(
|
||||
}
|
||||
},
|
||||
onOpenAdvanced = { navigator.push(RootScreen.Advanced) },
|
||||
onStartStopHaptic = haptics.contextClick,
|
||||
onStartStopHaptic = haptic::contextClick,
|
||||
onStart = {
|
||||
runBusy("启动 scrcpy") {
|
||||
startScrcpySession()
|
||||
@@ -1120,6 +1082,7 @@ fun DeviceTabPage(
|
||||
}
|
||||
|
||||
if (
|
||||
connectedVideoPlaybackEnabled &&
|
||||
sessionInfo != null &&
|
||||
sessionInfo!!.width > 0 &&
|
||||
sessionInfo!!.height > 0
|
||||
@@ -1135,23 +1098,20 @@ fun DeviceTabPage(
|
||||
imeRequestToken = imeRequestToken,
|
||||
onImeCommitText = { text -> commitImeText(text) },
|
||||
onImeDeleteSurroundingText = { beforeLength, _ ->
|
||||
withContext(Dispatchers.IO) {
|
||||
repeat(beforeLength.coerceAtLeast(1)) {
|
||||
scrcpy.injectKeycode(0, android.view.KeyEvent.KEYCODE_DEL)
|
||||
scrcpy.injectKeycode(1, android.view.KeyEvent.KEYCODE_DEL)
|
||||
}
|
||||
}
|
||||
submitImeDeleteSurroundingText(
|
||||
scrcpy = scrcpy,
|
||||
beforeLength = beforeLength,
|
||||
afterLength = 0,
|
||||
)
|
||||
},
|
||||
onImeKeyEvent = { event ->
|
||||
withContext(Dispatchers.IO) {
|
||||
scrcpy.injectKeycode(
|
||||
action = event.action,
|
||||
keycode = event.keyCode,
|
||||
repeat = event.repeatCount,
|
||||
metaState = event.metaState,
|
||||
)
|
||||
}
|
||||
true
|
||||
submitImeKeyEvent(
|
||||
scrcpy = scrcpy,
|
||||
event = event,
|
||||
keyInjectMode = sessionInfo?.keyInjectMode
|
||||
?: ClientOptions.KeyInjectMode.MIXED,
|
||||
forwardKeyRepeat = sessionInfo?.forwardKeyRepeat ?: true,
|
||||
)
|
||||
},
|
||||
autoBringIntoView = pendingScrollToPreview,
|
||||
onAutoBringIntoViewConsumed = { pendingScrollToPreview = false },
|
||||
|
||||
@@ -53,6 +53,7 @@ import androidx.fragment.app.FragmentActivity
|
||||
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
|
||||
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
|
||||
import io.github.miuzarte.scrcpyforandroid.password.PasswordPickerPopupContent
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.TouchEventHandler
|
||||
import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
|
||||
@@ -334,7 +335,11 @@ fun FullscreenControlScreen(
|
||||
}
|
||||
|
||||
suspend fun commitImeText(text: String) {
|
||||
submitImeText(scrcpy, text) { error, useClipboardPaste ->
|
||||
submitImeText(
|
||||
scrcpy = scrcpy,
|
||||
text = text,
|
||||
keyInjectMode = currentSession?.keyInjectMode ?: ClientOptions.KeyInjectMode.MIXED,
|
||||
) { error, useClipboardPaste ->
|
||||
Log.w("FullscreenControlPage", "commitImeText failed", error)
|
||||
snackbar.show(
|
||||
if (useClipboardPaste) "非 ASCII 文本粘贴失败"
|
||||
@@ -775,24 +780,20 @@ fun FullscreenControlPage(
|
||||
session = session,
|
||||
imeRequestToken = imeRequestToken,
|
||||
onImeCommitText = onImeCommitText,
|
||||
onImeDeleteSurroundingText = { beforeLength, _ ->
|
||||
withContext(Dispatchers.IO) {
|
||||
repeat(beforeLength.coerceAtLeast(1)) {
|
||||
scrcpy.injectKeycode(0, KeyEvent.KEYCODE_DEL)
|
||||
scrcpy.injectKeycode(1, KeyEvent.KEYCODE_DEL)
|
||||
}
|
||||
}
|
||||
onImeDeleteSurroundingText = { beforeLength, afterLength ->
|
||||
submitImeDeleteSurroundingText(
|
||||
scrcpy = scrcpy,
|
||||
beforeLength = beforeLength,
|
||||
afterLength = afterLength,
|
||||
)
|
||||
},
|
||||
onImeKeyEvent = { event ->
|
||||
withContext(Dispatchers.IO) {
|
||||
scrcpy.injectKeycode(
|
||||
action = event.action,
|
||||
keycode = event.keyCode,
|
||||
repeat = event.repeatCount,
|
||||
metaState = event.metaState,
|
||||
)
|
||||
}
|
||||
true
|
||||
submitImeKeyEvent(
|
||||
scrcpy = scrcpy,
|
||||
event = event,
|
||||
keyInjectMode = session.keyInjectMode,
|
||||
forwardKeyRepeat = session.forwardKeyRepeat,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package io.github.miuzarte.scrcpyforandroid.pages
|
||||
|
||||
import android.view.KeyEvent
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -7,9 +9,10 @@ import kotlinx.coroutines.withContext
|
||||
internal suspend fun submitImeText(
|
||||
scrcpy: Scrcpy,
|
||||
text: String,
|
||||
keyInjectMode: ClientOptions.KeyInjectMode = ClientOptions.KeyInjectMode.MIXED,
|
||||
onFailure: suspend (error: Throwable, useClipboardPaste: Boolean) -> Unit,
|
||||
) {
|
||||
if (text.isBlank()) {
|
||||
if (text.isBlank() || keyInjectMode == ClientOptions.KeyInjectMode.RAW) {
|
||||
return
|
||||
}
|
||||
val useClipboardPaste = text.any { it.code > 0x7F }
|
||||
@@ -25,3 +28,43 @@ internal suspend fun submitImeText(
|
||||
onFailure(error, useClipboardPaste)
|
||||
}
|
||||
}
|
||||
|
||||
internal suspend fun submitImeKeyEvent(
|
||||
scrcpy: Scrcpy,
|
||||
event: KeyEvent,
|
||||
keyInjectMode: ClientOptions.KeyInjectMode,
|
||||
forwardKeyRepeat: Boolean,
|
||||
): Boolean {
|
||||
if (keyInjectMode == ClientOptions.KeyInjectMode.PREFER_TEXT) {
|
||||
return false
|
||||
}
|
||||
if (!forwardKeyRepeat && event.repeatCount > 0) {
|
||||
return true
|
||||
}
|
||||
withContext(Dispatchers.IO) {
|
||||
scrcpy.injectKeycode(
|
||||
action = event.action,
|
||||
keycode = event.keyCode,
|
||||
repeat = if (forwardKeyRepeat) event.repeatCount else 0,
|
||||
metaState = event.metaState,
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
internal suspend fun submitImeDeleteSurroundingText(
|
||||
scrcpy: Scrcpy,
|
||||
beforeLength: Int,
|
||||
afterLength: Int,
|
||||
) {
|
||||
withContext(Dispatchers.IO) {
|
||||
repeat(beforeLength.coerceAtLeast(0)) {
|
||||
scrcpy.injectKeycode(0, KeyEvent.KEYCODE_DEL)
|
||||
scrcpy.injectKeycode(1, KeyEvent.KEYCODE_DEL)
|
||||
}
|
||||
repeat(afterLength.coerceAtLeast(0)) {
|
||||
scrcpy.injectKeycode(0, KeyEvent.KEYCODE_FORWARD_DEL)
|
||||
scrcpy.injectKeycode(1, KeyEvent.KEYCODE_FORWARD_DEL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,12 +60,15 @@ import io.github.miuzarte.scrcpyforandroid.BuildConfig
|
||||
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
|
||||
import io.github.miuzarte.scrcpyforandroid.constants.ThemeModes
|
||||
import io.github.miuzarte.scrcpyforandroid.constants.UiMotion
|
||||
import io.github.miuzarte.scrcpyforandroid.haptics.LocalAppHaptics
|
||||
import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
|
||||
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
||||
import io.github.miuzarte.scrcpyforandroid.services.AppScreenOn
|
||||
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
|
||||
import io.github.miuzarte.scrcpyforandroid.services.AppUpdateChecker
|
||||
import io.github.miuzarte.scrcpyforandroid.services.ConnectionController
|
||||
import io.github.miuzarte.scrcpyforandroid.services.ConnectionStateStore
|
||||
import io.github.miuzarte.scrcpyforandroid.services.DeviceAdbAutoReconnectManager
|
||||
import io.github.miuzarte.scrcpyforandroid.services.DeviceAdbConnectionCoordinator
|
||||
import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
|
||||
import io.github.miuzarte.scrcpyforandroid.services.SnackbarController
|
||||
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
|
||||
@@ -147,6 +150,7 @@ sealed interface RootScreen : NavKey {
|
||||
data object Advanced : RootScreen
|
||||
data object About : RootScreen
|
||||
data object VirtualButtonOrder : RootScreen
|
||||
data class ScrcpyOptionRecord(val profileId: String) : RootScreen
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -168,7 +172,6 @@ fun MainScreen() {
|
||||
hostState = snackHostState,
|
||||
)
|
||||
}
|
||||
val haptics = rememberAppHaptics()
|
||||
|
||||
// Root navigation and UI chrome state
|
||||
val saveableStateHolder = rememberSaveableStateHolder()
|
||||
@@ -202,6 +205,7 @@ fun MainScreen() {
|
||||
when (currentRootScreen) {
|
||||
is RootScreen.Advanced -> true
|
||||
is RootScreen.VirtualButtonOrder -> true
|
||||
is RootScreen.ScrcpyOptionRecord -> true
|
||||
else -> false
|
||||
}
|
||||
})
|
||||
@@ -291,6 +295,31 @@ fun MainScreen() {
|
||||
}
|
||||
|
||||
val currentSession by scrcpy.currentSessionState.collectAsState()
|
||||
val deviceConnectionServices = remember(scrcpy) {
|
||||
val adbCoordinator = DeviceAdbConnectionCoordinator()
|
||||
val connectionStateStore = ConnectionStateStore()
|
||||
val connectionController = ConnectionController(
|
||||
scrcpy = scrcpy,
|
||||
stateStore = connectionStateStore,
|
||||
adbCoordinator = adbCoordinator,
|
||||
)
|
||||
val autoReconnectManager = DeviceAdbAutoReconnectManager(
|
||||
controller = connectionController,
|
||||
stateStore = connectionStateStore,
|
||||
)
|
||||
DeviceConnectionServices(
|
||||
adbCoordinator = adbCoordinator,
|
||||
connectionStateStore = connectionStateStore,
|
||||
connectionController = connectionController,
|
||||
autoReconnectManager = autoReconnectManager,
|
||||
)
|
||||
}
|
||||
DisposableEffect(deviceConnectionServices) {
|
||||
onDispose {
|
||||
deviceConnectionServices.autoReconnectManager.close()
|
||||
AppScreenOn.release()
|
||||
}
|
||||
}
|
||||
|
||||
// Side-effect launchers and composition locals
|
||||
val picker = rememberLauncherForActivityResult(
|
||||
@@ -530,6 +559,7 @@ fun MainScreen() {
|
||||
MainBottomTabDestination.Devices -> DeviceTabScreen(
|
||||
scrollBehavior = devicesPageScrollBehavior,
|
||||
scrcpy = scrcpy,
|
||||
connectionServices = deviceConnectionServices,
|
||||
bottomInnerPadding = bottomInnerPadding,
|
||||
onOpenReorderDevices = { showReorderDevices = true },
|
||||
)
|
||||
@@ -625,6 +655,14 @@ fun MainScreen() {
|
||||
)
|
||||
}
|
||||
|
||||
entry<RootScreen.ScrcpyOptionRecord> { route ->
|
||||
RecordPreferencesScreen(
|
||||
scrollBehavior = advancedPageScrollBehavior,
|
||||
profileId = route.profileId,
|
||||
scrcpy = scrcpy,
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
val rootEntries = rememberDecoratedNavEntries(
|
||||
@@ -649,7 +687,6 @@ fun MainScreen() {
|
||||
LocalEnableFloatingBottomBarBlur provides asBundle.floatingBottomBarBlur,
|
||||
LocalRootNavigator provides rootNavigator,
|
||||
LocalSnackbarController provides snackbarController,
|
||||
LocalAppHaptics provides haptics,
|
||||
LocalServerPicker provides serverPicker,
|
||||
LocalTerminalFontPicker provides terminalFontPicker,
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
package io.github.miuzarte.scrcpyforandroid.pages
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
|
||||
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared
|
||||
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
|
||||
import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
|
||||
import io.github.miuzarte.scrcpyforandroid.services.RecordFilenameTemplate
|
||||
import io.github.miuzarte.scrcpyforandroid.services.RecordingFileResolver
|
||||
import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions
|
||||
import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyProfiles
|
||||
import io.github.miuzarte.scrcpyforandroid.storage.Settings
|
||||
import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyOptions
|
||||
import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyProfiles
|
||||
import io.github.miuzarte.scrcpyforandroid.ui.BlurredBar
|
||||
import io.github.miuzarte.scrcpyforandroid.ui.LocalEnableBlur
|
||||
import io.github.miuzarte.scrcpyforandroid.ui.rememberBlurBackdrop
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import top.yukonga.miuix.kmp.basic.Card
|
||||
import top.yukonga.miuix.kmp.basic.HorizontalDivider
|
||||
import top.yukonga.miuix.kmp.basic.Icon
|
||||
import top.yukonga.miuix.kmp.basic.IconButton
|
||||
import top.yukonga.miuix.kmp.basic.Scaffold
|
||||
import top.yukonga.miuix.kmp.basic.ScrollBehavior
|
||||
import top.yukonga.miuix.kmp.basic.SnackbarHost
|
||||
import top.yukonga.miuix.kmp.basic.Text
|
||||
import top.yukonga.miuix.kmp.basic.TextButton
|
||||
import top.yukonga.miuix.kmp.basic.TextField
|
||||
import top.yukonga.miuix.kmp.basic.TopAppBar
|
||||
import top.yukonga.miuix.kmp.blur.layerBackdrop
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme.textStyles
|
||||
|
||||
@Composable
|
||||
internal fun RecordPreferencesScreen(
|
||||
scrollBehavior: ScrollBehavior,
|
||||
profileId: String,
|
||||
scrcpy: Scrcpy,
|
||||
) {
|
||||
val navigator = LocalRootNavigator.current
|
||||
val snackbar = LocalSnackbarController.current
|
||||
val blurBackdrop = rememberBlurBackdrop(LocalEnableBlur.current)
|
||||
val blurActive = blurBackdrop != null
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
snackbarHost = { SnackbarHost(snackbar.hostState) },
|
||||
topBar = {
|
||||
BlurredBar(backdrop = blurBackdrop) {
|
||||
TopAppBar(
|
||||
title = "录制",
|
||||
color =
|
||||
if (blurActive) Color.Transparent
|
||||
else colorScheme.surface,
|
||||
navigationIcon = {
|
||||
IconButton(onClick = navigator.pop) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Rounded.ArrowBack,
|
||||
contentDescription = "返回",
|
||||
)
|
||||
}
|
||||
},
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
}
|
||||
},
|
||||
) { pagePadding ->
|
||||
Box(modifier = if (blurActive) Modifier.layerBackdrop(blurBackdrop) else Modifier) {
|
||||
RecordPreferencesPage(
|
||||
contentPadding = pagePadding,
|
||||
scrollBehavior = scrollBehavior,
|
||||
profileId = profileId,
|
||||
scrcpy = scrcpy,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun RecordPreferencesPage(
|
||||
contentPadding: PaddingValues,
|
||||
scrollBehavior: ScrollBehavior,
|
||||
profileId: String,
|
||||
scrcpy: Scrcpy,
|
||||
) {
|
||||
val focusManager = LocalFocusManager.current
|
||||
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
|
||||
val currentSession by scrcpy.currentSessionState.collectAsState()
|
||||
|
||||
val soBundleShared by scrcpyOptions.bundleState.collectAsState()
|
||||
val scrcpyProfilesState by scrcpyProfiles.state.collectAsState()
|
||||
val sourceBundle = remember(profileId, soBundleShared, scrcpyProfilesState) {
|
||||
recordBundleForProfile(
|
||||
profileId = profileId,
|
||||
globalBundle = soBundleShared,
|
||||
profilesState = scrcpyProfilesState,
|
||||
)
|
||||
}
|
||||
val sourceBundleLatest by rememberUpdatedState(sourceBundle)
|
||||
var soBundle by rememberSaveable(profileId, sourceBundle) { mutableStateOf(sourceBundle) }
|
||||
val soBundleLatest by rememberUpdatedState(soBundle)
|
||||
|
||||
LaunchedEffect(sourceBundle) {
|
||||
if (soBundle != sourceBundle) {
|
||||
soBundle = sourceBundle
|
||||
}
|
||||
}
|
||||
LaunchedEffect(soBundle) {
|
||||
delay(Settings.BUNDLE_SAVE_DELAY)
|
||||
if (soBundle != sourceBundleLatest) {
|
||||
saveRecordBundleForProfile(profileId, soBundle)
|
||||
}
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
taskScope.launch {
|
||||
saveRecordBundleForProfile(profileId, soBundleLatest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var draftTemplate by rememberSaveable(
|
||||
profileId,
|
||||
stateSaver = TextFieldValue.Saver,
|
||||
) {
|
||||
mutableStateOf(
|
||||
TextFieldValue(
|
||||
text = soBundle.recordFilename,
|
||||
selection = TextRange(soBundle.recordFilename.length),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(sourceBundle.recordFilename) {
|
||||
if (draftTemplate.text != sourceBundle.recordFilename) {
|
||||
draftTemplate = TextFieldValue(
|
||||
text = sourceBundle.recordFilename,
|
||||
selection = TextRange(sourceBundle.recordFilename.length),
|
||||
)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(draftTemplate.text, profileId) {
|
||||
delay(Settings.BUNDLE_SAVE_DELAY)
|
||||
val trimmed = draftTemplate.text.trim()
|
||||
if (trimmed != sourceBundleLatest.recordFilename) {
|
||||
saveRecordBundleForProfile(
|
||||
profileId = profileId,
|
||||
bundle = soBundleLatest.copy(recordFilename = trimmed),
|
||||
)
|
||||
}
|
||||
}
|
||||
val draftTemplateLatest by rememberUpdatedState(draftTemplate)
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
val trimmed = draftTemplateLatest.text.trim()
|
||||
if (trimmed != soBundleLatest.recordFilename) {
|
||||
taskScope.launch {
|
||||
saveRecordBundleForProfile(
|
||||
profileId = profileId,
|
||||
bundle = soBundleLatest.copy(recordFilename = trimmed),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val entries = remember { RecordFilenameTemplate.entries }
|
||||
val previewSessionInfo = remember(currentSession) {
|
||||
currentSession ?: Scrcpy.Session.SessionInfo(
|
||||
deviceName = AppRuntime.currentConnectedDevice?.model ?: "Unknown",
|
||||
codecId = 0,
|
||||
codec = Shared.Codec.H264,
|
||||
width = 1920,
|
||||
height = 1080,
|
||||
audioCodecId = 0,
|
||||
audioCodec = Shared.Codec.OPUS,
|
||||
controlEnabled = true,
|
||||
host = AppRuntime.currentConnectionTarget?.host ?: "192.168.1.100",
|
||||
port = AppRuntime.currentConnectionTarget?.port ?: 5555,
|
||||
)
|
||||
}
|
||||
val previewFilename = remember(draftTemplate.text) {
|
||||
RecordingFileResolver.sanitizeFileName(
|
||||
RecordFilenameTemplate.resolve(
|
||||
template = draftTemplate.text,
|
||||
sessionInfo = previewSessionInfo,
|
||||
)
|
||||
)
|
||||
}
|
||||
val listState = rememberSaveable(
|
||||
profileId,
|
||||
saver = LazyListState.Saver,
|
||||
) {
|
||||
LazyListState()
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
contentPadding = contentPadding,
|
||||
scrollBehavior = scrollBehavior,
|
||||
state = listState,
|
||||
bottomInnerPadding = UiSpacing.PageBottom,
|
||||
) {
|
||||
item {
|
||||
TextField(
|
||||
value = draftTemplate,
|
||||
onValueChange = { draftTemplate = it.sanitizeRecordFilenameInput() },
|
||||
label = "文件名,不为空时启用",
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
|
||||
)
|
||||
}
|
||||
|
||||
if (draftTemplate.text.isNotEmpty()) item {
|
||||
Text(
|
||||
text = "预览:\n$previewFilename",
|
||||
color = colorScheme.onSurfaceVariantSummary,
|
||||
fontSize = textStyles.body2.fontSize,
|
||||
modifier = Modifier.padding(horizontal = UiSpacing.Large),
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Card {
|
||||
FlowRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(UiSpacing.Large),
|
||||
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
|
||||
verticalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
|
||||
) {
|
||||
entries.forEach { entry ->
|
||||
TextButton(
|
||||
text = entry.value,
|
||||
onClick = {
|
||||
draftTemplate = draftTemplate.replaceSelection(entry.value)
|
||||
},
|
||||
insideMargin = PaddingValues(horizontal = 10.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Card {
|
||||
Column(
|
||||
modifier = Modifier.padding(vertical = UiSpacing.Large),
|
||||
verticalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
|
||||
) {
|
||||
entries.filter { it.isTemplate }.forEachIndexed { index, entry ->
|
||||
if (index > 0) HorizontalDivider()
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = UiSpacing.Large),
|
||||
verticalArrangement = Arrangement.spacedBy(UiSpacing.Small)
|
||||
) {
|
||||
Text(
|
||||
text = entry.value,
|
||||
color = if (entry.isTemplate) colorScheme.primary else colorScheme.onSurface,
|
||||
fontSize = textStyles.body1.fontSize,
|
||||
)
|
||||
Text(
|
||||
text = entry.description ?: "纯文本",
|
||||
color = colorScheme.onSurfaceVariantSummary,
|
||||
fontSize = textStyles.body2.fontSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun recordBundleForProfile(
|
||||
profileId: String,
|
||||
globalBundle: ScrcpyOptions.Bundle,
|
||||
profilesState: ScrcpyProfiles.State,
|
||||
): ScrcpyOptions.Bundle {
|
||||
return if (profileId == ScrcpyOptions.GLOBAL_PROFILE_ID) {
|
||||
globalBundle
|
||||
} else {
|
||||
profilesState.profiles
|
||||
.firstOrNull { it.id == profileId }
|
||||
?.bundle
|
||||
?: globalBundle
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun saveRecordBundleForProfile(
|
||||
profileId: String,
|
||||
bundle: ScrcpyOptions.Bundle,
|
||||
) {
|
||||
val normalizedBundle = bundle.copy(recordFilename = bundle.recordFilename.trim())
|
||||
if (profileId == ScrcpyOptions.GLOBAL_PROFILE_ID) {
|
||||
scrcpyOptions.saveBundle(normalizedBundle)
|
||||
} else {
|
||||
scrcpyProfiles.updateBundle(profileId, normalizedBundle)
|
||||
}
|
||||
}
|
||||
|
||||
private fun TextFieldValue.replaceSelection(replacement: String): TextFieldValue {
|
||||
val start = selection.min
|
||||
val end = selection.max
|
||||
val updatedText = buildString {
|
||||
append(text.substring(0, start))
|
||||
append(replacement)
|
||||
append(text.substring(end))
|
||||
}
|
||||
val cursor = start + replacement.length
|
||||
return copy(
|
||||
text = updatedText,
|
||||
selection = TextRange(cursor),
|
||||
).sanitizeRecordFilenameInput()
|
||||
}
|
||||
|
||||
private fun TextFieldValue.sanitizeRecordFilenameInput(): TextFieldValue {
|
||||
val sanitizedText = RecordingFileResolver.sanitizeFileName(text)
|
||||
if (sanitizedText == text) {
|
||||
return this
|
||||
}
|
||||
val sanitizedSelection = TextRange(
|
||||
start = selection.start.coerceIn(0, sanitizedText.length),
|
||||
end = selection.end.coerceIn(0, sanitizedText.length),
|
||||
)
|
||||
return copy(
|
||||
text = sanitizedText,
|
||||
selection = sanitizedSelection,
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import android.annotation.SuppressLint
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -11,6 +12,8 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -37,6 +40,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusDirection
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
@@ -51,6 +55,7 @@ import io.github.miuzarte.scrcpyforandroid.scaffolds.ReorderableList
|
||||
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlider
|
||||
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSpinner
|
||||
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.AudioSource
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.CameraFacing
|
||||
@@ -70,6 +75,7 @@ import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyProfiles
|
||||
import io.github.miuzarte.scrcpyforandroid.ui.BlurredBar
|
||||
import io.github.miuzarte.scrcpyforandroid.ui.LocalEnableBlur
|
||||
import io.github.miuzarte.scrcpyforandroid.ui.rememberBlurBackdrop
|
||||
import io.github.miuzarte.scrcpyforandroid.widgets.RecordPreferences
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -415,6 +421,7 @@ internal fun ScrcpyAllOptionsPage(
|
||||
onSaveBundleForProfile: suspend (String, ScrcpyOptions.Bundle) -> Unit,
|
||||
) {
|
||||
val focusManager = LocalFocusManager.current
|
||||
val resources = LocalResources.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val taskScope = remember { CoroutineScope(Dispatchers.IO + SupervisorJob()) }
|
||||
val snackbar = LocalSnackbarController.current
|
||||
@@ -455,6 +462,11 @@ internal fun ScrcpyAllOptionsPage(
|
||||
}
|
||||
}
|
||||
}
|
||||
val listState = rememberSaveable(
|
||||
saver = LazyListState.Saver,
|
||||
) {
|
||||
LazyListState()
|
||||
}
|
||||
|
||||
val audioCodecItems = rememberSaveable { Codec.AUDIO.map { it.displayName } }
|
||||
val audioCodecIndex = rememberSaveable(soBundle.audioCodec) {
|
||||
@@ -613,6 +625,16 @@ internal fun ScrcpyAllOptionsPage(
|
||||
.indexOfFirst { it.string == soBundle.audioSource }
|
||||
.coerceAtLeast(0)
|
||||
}
|
||||
val keyInjectModeItems = rememberSaveable {
|
||||
listOf("默认", "优先文本", "原始按键事件")
|
||||
}
|
||||
val keyInjectModeIndex = rememberSaveable(soBundle.keyInjectMode) {
|
||||
when (soBundle.keyInjectMode) {
|
||||
ClientOptions.KeyInjectMode.PREFER_TEXT.string -> 1
|
||||
ClientOptions.KeyInjectMode.RAW.string -> 2
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
|
||||
val maxSizePresetIndex = rememberSaveable(soBundle.maxSize) {
|
||||
ScrcpyPresets.MaxSize.indexOfOrNearest(soBundle.maxSize)
|
||||
@@ -782,6 +804,16 @@ internal fun ScrcpyAllOptionsPage(
|
||||
var newDisplayDpiInput by rememberSaveable(soBundle.newDisplay) {
|
||||
mutableStateOf(ndDpi?.toString() ?: "")
|
||||
}
|
||||
val newDisplayWidthBlank = remember(newDisplayWidthInput) {
|
||||
newDisplayWidthInput.trim().isEmpty()
|
||||
}
|
||||
val newDisplayHeightBlank = remember(newDisplayHeightInput) {
|
||||
newDisplayHeightInput.trim().isEmpty()
|
||||
}
|
||||
val newDisplayAllBlank =
|
||||
remember(newDisplayWidthBlank, newDisplayHeightBlank, newDisplayDpiInput) {
|
||||
newDisplayWidthBlank && newDisplayHeightBlank && newDisplayDpiInput.trim().isEmpty()
|
||||
}
|
||||
|
||||
// width:height:x:y
|
||||
val (cWidth, cHeight, cX, cY) = remember(soBundle.crop) {
|
||||
@@ -834,6 +866,7 @@ internal fun ScrcpyAllOptionsPage(
|
||||
LazyColumn(
|
||||
contentPadding = contentPadding,
|
||||
scrollBehavior = scrollBehavior,
|
||||
state = listState,
|
||||
bottomInnerPadding = UiSpacing.PageBottom,
|
||||
) {
|
||||
item {
|
||||
@@ -874,8 +907,6 @@ internal fun ScrcpyAllOptionsPage(
|
||||
control = !it
|
||||
)
|
||||
},
|
||||
// 拦不住同时点, 弃用
|
||||
// enabled = audio || video,
|
||||
)
|
||||
SwitchPreference(
|
||||
title = "禁用视频",
|
||||
@@ -886,7 +917,17 @@ internal fun ScrcpyAllOptionsPage(
|
||||
video = !it
|
||||
)
|
||||
},
|
||||
// enabled = audio || control,
|
||||
)
|
||||
SwitchPreference(
|
||||
title = "仅转发不播放视频",
|
||||
summary = "--no-video-playback",
|
||||
checked = !soBundle.videoPlayback,
|
||||
onCheckedChange = {
|
||||
soBundle = soBundle.copy(
|
||||
videoPlayback = !it
|
||||
)
|
||||
},
|
||||
enabled = soBundle.video,
|
||||
)
|
||||
SwitchPreference(
|
||||
title = "禁用音频",
|
||||
@@ -897,7 +938,17 @@ internal fun ScrcpyAllOptionsPage(
|
||||
audio = !it
|
||||
)
|
||||
},
|
||||
// enabled = control || video,
|
||||
)
|
||||
SwitchPreference(
|
||||
title = "仅转发不播放",
|
||||
summary = "--no-audio-playback",
|
||||
checked = !soBundle.audioPlayback,
|
||||
onCheckedChange = {
|
||||
soBundle = soBundle.copy(
|
||||
audioPlayback = !it
|
||||
)
|
||||
},
|
||||
enabled = soBundle.audio,
|
||||
)
|
||||
SuperSlider(
|
||||
title = "scrcpy 启动后受控机的息屏时间",
|
||||
@@ -1000,6 +1051,15 @@ internal fun ScrcpyAllOptionsPage(
|
||||
)
|
||||
},
|
||||
)
|
||||
RecordPreferences(
|
||||
profileId = selectedProfileId,
|
||||
recordFilenameTemplate = soBundle.recordFilename,
|
||||
recordFormat = soBundle.recordFormat,
|
||||
enabled = true,
|
||||
onRecordFormatChange = {
|
||||
soBundle = soBundle.copy(recordFormat = it)
|
||||
},
|
||||
)
|
||||
SwitchPreference(
|
||||
title = "scrcpy 结束时断开 adb",
|
||||
summary = "--kill-adb-on-close",
|
||||
@@ -1437,16 +1497,6 @@ internal fun ScrcpyAllOptionsPage(
|
||||
)
|
||||
},
|
||||
)
|
||||
SwitchPreference(
|
||||
title = "仅转发不播放",
|
||||
summary = "--no-audio-playback",
|
||||
checked = !soBundle.audioPlayback,
|
||||
onCheckedChange = {
|
||||
soBundle = soBundle.copy(
|
||||
audioPlayback = !it
|
||||
)
|
||||
},
|
||||
)
|
||||
SwitchPreference(
|
||||
title = "音频转发失败时终止",
|
||||
summary = "--require-audio",
|
||||
@@ -1601,6 +1651,36 @@ internal fun ScrcpyAllOptionsPage(
|
||||
)
|
||||
},
|
||||
)
|
||||
OverlayDropdownPreference(
|
||||
title = "键盘注入策略",
|
||||
summary = when (keyInjectModeIndex) {
|
||||
1 -> "--prefer-text"
|
||||
2 -> "--raw-key-events"
|
||||
else -> "默认"
|
||||
},
|
||||
items = keyInjectModeItems,
|
||||
selectedIndex = keyInjectModeIndex,
|
||||
onSelectedIndexChange = {
|
||||
soBundle = soBundle.copy(
|
||||
keyInjectMode = when (it) {
|
||||
1 -> ClientOptions.KeyInjectMode.PREFER_TEXT.string
|
||||
2 -> ClientOptions.KeyInjectMode.RAW.string
|
||||
else -> ClientOptions.KeyInjectMode.MIXED.string
|
||||
}
|
||||
)
|
||||
},
|
||||
)
|
||||
SwitchPreference(
|
||||
title = "禁用按键重复转发",
|
||||
summary = "--no-key-repeat",
|
||||
checked = !soBundle.forwardKeyRepeat,
|
||||
onCheckedChange = {
|
||||
soBundle = soBundle.copy(
|
||||
forwardKeyRepeat = !it
|
||||
)
|
||||
},
|
||||
enabled = soBundle.keyInjectMode != ClientOptions.KeyInjectMode.PREFER_TEXT.string,
|
||||
)
|
||||
SwitchPreference(
|
||||
title = "禁用剪贴板双向同步",
|
||||
summary = "--no-clipboard-autosync",
|
||||
@@ -1745,13 +1825,11 @@ internal fun ScrcpyAllOptionsPage(
|
||||
onValueChange = { newDisplayWidthInput = it },
|
||||
onFocusLost = {
|
||||
soBundle = soBundle.copy(
|
||||
newDisplay = NewDisplay
|
||||
.parseFrom(
|
||||
newDisplayWidthInput,
|
||||
newDisplayHeightInput,
|
||||
newDisplayDpiInput
|
||||
)
|
||||
.toString()
|
||||
newDisplay = NewDisplay.parseFrom(
|
||||
newDisplayWidthInput,
|
||||
newDisplayHeightInput,
|
||||
newDisplayDpiInput
|
||||
).toString()
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
@@ -1770,13 +1848,11 @@ internal fun ScrcpyAllOptionsPage(
|
||||
onValueChange = { newDisplayHeightInput = it },
|
||||
onFocusLost = {
|
||||
soBundle = soBundle.copy(
|
||||
newDisplay = NewDisplay
|
||||
.parseFrom(
|
||||
newDisplayWidthInput,
|
||||
newDisplayHeightInput,
|
||||
newDisplayDpiInput
|
||||
)
|
||||
.toString()
|
||||
newDisplay = NewDisplay.parseFrom(
|
||||
newDisplayWidthInput,
|
||||
newDisplayHeightInput,
|
||||
newDisplayDpiInput
|
||||
).toString()
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
@@ -1795,13 +1871,11 @@ internal fun ScrcpyAllOptionsPage(
|
||||
onValueChange = { newDisplayDpiInput = it },
|
||||
onFocusLost = {
|
||||
soBundle = soBundle.copy(
|
||||
newDisplay = NewDisplay
|
||||
.parseFrom(
|
||||
newDisplayWidthInput,
|
||||
newDisplayHeightInput,
|
||||
newDisplayDpiInput
|
||||
)
|
||||
.toString()
|
||||
newDisplay = NewDisplay.parseFrom(
|
||||
newDisplayWidthInput,
|
||||
newDisplayHeightInput,
|
||||
newDisplayDpiInput
|
||||
).toString()
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
@@ -1816,6 +1890,59 @@ internal fun ScrcpyAllOptionsPage(
|
||||
)
|
||||
}
|
||||
}
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = UiSpacing.Large),
|
||||
) {
|
||||
val gap = UiSpacing.ContentHorizontal
|
||||
val inputWidth = (maxWidth - gap * 2) / 3
|
||||
val trailingButtonWidth = inputWidth * 2 + gap
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(gap),
|
||||
) {
|
||||
TextButton(
|
||||
text = "清空",
|
||||
onClick = {
|
||||
newDisplayWidthInput = ""
|
||||
newDisplayHeightInput = ""
|
||||
newDisplayDpiInput = ""
|
||||
soBundle = soBundle.copy(newDisplay = "")
|
||||
},
|
||||
modifier = Modifier.width(inputWidth),
|
||||
enabled = !newDisplayAllBlank,
|
||||
)
|
||||
TextButton(
|
||||
text =
|
||||
if (newDisplayWidthBlank || newDisplayHeightBlank) "本机"
|
||||
else "交换",
|
||||
onClick = {
|
||||
if (newDisplayWidthBlank || newDisplayHeightBlank) {
|
||||
val metrics = resources.displayMetrics
|
||||
newDisplayWidthInput = metrics.widthPixels.toString()
|
||||
newDisplayHeightInput = metrics.heightPixels.toString()
|
||||
newDisplayDpiInput = metrics.densityDpi
|
||||
.takeIf { it > 0 }
|
||||
?.toString()
|
||||
.orEmpty()
|
||||
} else {
|
||||
val currentWidth = newDisplayWidthInput.trim()
|
||||
val currentHeight = newDisplayHeightInput.trim()
|
||||
newDisplayWidthInput = currentHeight
|
||||
newDisplayHeightInput = currentWidth
|
||||
}
|
||||
soBundle = soBundle.copy(
|
||||
newDisplay = NewDisplay.parseFrom(
|
||||
newDisplayWidthInput,
|
||||
newDisplayHeightInput,
|
||||
newDisplayDpiInput
|
||||
).toString()
|
||||
)
|
||||
},
|
||||
modifier = Modifier.width(trailingButtonWidth),
|
||||
colors = ButtonDefaults.textButtonColorsPrimary(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1845,14 +1972,12 @@ internal fun ScrcpyAllOptionsPage(
|
||||
onValueChange = { cropWidthInput = it },
|
||||
onFocusLost = {
|
||||
soBundle = soBundle.copy(
|
||||
crop = Crop
|
||||
.parseFrom(
|
||||
cropWidthInput,
|
||||
cropHeightInput,
|
||||
cropXInput,
|
||||
cropYInput
|
||||
)
|
||||
.toString()
|
||||
crop = Crop.parseFrom(
|
||||
cropWidthInput,
|
||||
cropHeightInput,
|
||||
cropXInput,
|
||||
cropYInput
|
||||
).toString()
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
@@ -1871,14 +1996,12 @@ internal fun ScrcpyAllOptionsPage(
|
||||
onValueChange = { cropHeightInput = it },
|
||||
onFocusLost = {
|
||||
soBundle = soBundle.copy(
|
||||
crop = Crop
|
||||
.parseFrom(
|
||||
cropWidthInput,
|
||||
cropHeightInput,
|
||||
cropXInput,
|
||||
cropYInput
|
||||
)
|
||||
.toString()
|
||||
crop = Crop.parseFrom(
|
||||
cropWidthInput,
|
||||
cropHeightInput,
|
||||
cropXInput,
|
||||
cropYInput
|
||||
).toString()
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
@@ -1902,14 +2025,12 @@ internal fun ScrcpyAllOptionsPage(
|
||||
onValueChange = { cropXInput = it },
|
||||
onFocusLost = {
|
||||
soBundle = soBundle.copy(
|
||||
crop = Crop
|
||||
.parseFrom(
|
||||
cropWidthInput,
|
||||
cropHeightInput,
|
||||
cropXInput,
|
||||
cropYInput
|
||||
)
|
||||
.toString()
|
||||
crop = Crop.parseFrom(
|
||||
cropWidthInput,
|
||||
cropHeightInput,
|
||||
cropXInput,
|
||||
cropYInput
|
||||
).toString()
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
@@ -1928,14 +2049,12 @@ internal fun ScrcpyAllOptionsPage(
|
||||
onValueChange = { cropYInput = it },
|
||||
onFocusLost = {
|
||||
soBundle = soBundle.copy(
|
||||
crop = Crop
|
||||
.parseFrom(
|
||||
cropWidthInput,
|
||||
cropHeightInput,
|
||||
cropXInput,
|
||||
cropYInput
|
||||
)
|
||||
.toString()
|
||||
crop = Crop.parseFrom(
|
||||
cropWidthInput,
|
||||
cropHeightInput,
|
||||
cropXInput,
|
||||
cropYInput
|
||||
).toString()
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Clear
|
||||
import androidx.compose.material.icons.rounded.FileOpen
|
||||
@@ -208,11 +209,15 @@ fun SettingsPage(
|
||||
}
|
||||
}
|
||||
}
|
||||
val listState = rememberSaveable(saver = LazyListState.Saver) {
|
||||
LazyListState()
|
||||
}
|
||||
|
||||
// 设置
|
||||
LazyColumn(
|
||||
contentPadding = contentPadding,
|
||||
scrollBehavior = scrollBehavior,
|
||||
state = listState,
|
||||
bottomInnerPadding = bottomInnerPadding,
|
||||
) {
|
||||
item {
|
||||
@@ -236,6 +241,9 @@ fun SettingsPage(
|
||||
asBundle = asBundle.copy(monet = it)
|
||||
},
|
||||
)
|
||||
// AnimatedVisibility(asBundle.monet) {
|
||||
// // TODO: 自选 Monet 强调色方案,从默认开始
|
||||
// }
|
||||
SwitchPreference(
|
||||
title = "模糊",
|
||||
summary = "启用顶栏和底栏的模糊效果",
|
||||
@@ -370,7 +378,7 @@ fun SettingsPage(
|
||||
AnimatedVisibility(asBundle.showFullscreenVirtualButtons) {
|
||||
Column {
|
||||
MultiGroupsDropdownPreference(
|
||||
title = "全屏虚拟按钮方向",
|
||||
title = "虚拟按钮方向",
|
||||
summary = fullscreenVirtualButtonDock.summary,
|
||||
groups = listOf(
|
||||
MultiGroupsDropdownGroup(
|
||||
@@ -404,8 +412,7 @@ fun SettingsPage(
|
||||
),
|
||||
)
|
||||
SuperSlider(
|
||||
title = "全屏虚拟按钮高度",
|
||||
summary = "调整全屏控制页中虚拟按钮栏的高度",
|
||||
title = "虚拟按钮高度",
|
||||
value = asBundle.fullscreenVirtualButtonHeightDp.toFloat(),
|
||||
onValueChange = {
|
||||
asBundle = asBundle.copy(
|
||||
@@ -442,8 +449,7 @@ fun SettingsPage(
|
||||
AnimatedVisibility(asBundle.showFullscreenFloatingButton) {
|
||||
Column {
|
||||
SuperSlider(
|
||||
title = "全屏悬浮球尺寸",
|
||||
summary = "调整全屏控制页悬浮球大小",
|
||||
title = "悬浮球尺寸",
|
||||
value = asBundle.fullscreenFloatingButtonSizeDp.toFloat(),
|
||||
onValueChange = {
|
||||
asBundle = asBundle.copy(
|
||||
@@ -469,7 +475,6 @@ fun SettingsPage(
|
||||
)
|
||||
SuperSlider(
|
||||
title = "悬浮球背景透明度",
|
||||
summary = "调整悬浮球背景透明度",
|
||||
value = asBundle.fullscreenFloatingButtonBackgroundAlphaPercent.toFloat(),
|
||||
onValueChange = {
|
||||
asBundle = asBundle.copy(
|
||||
@@ -494,8 +499,7 @@ fun SettingsPage(
|
||||
},
|
||||
)
|
||||
SuperSlider(
|
||||
title = "白环透明度",
|
||||
summary = "调整悬浮球白环透明度",
|
||||
title = "悬浮球白环透明度",
|
||||
value = asBundle.fullscreenFloatingButtonRingAlphaPercent.toFloat(),
|
||||
onValueChange = {
|
||||
asBundle = asBundle.copy(
|
||||
|
||||
@@ -15,8 +15,6 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import io.github.miuzarte.scrcpyforandroid.StreamActivity
|
||||
import io.github.miuzarte.scrcpyforandroid.constants.ThemeModes
|
||||
import io.github.miuzarte.scrcpyforandroid.haptics.LocalAppHaptics
|
||||
import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
|
||||
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
|
||||
import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
|
||||
import io.github.miuzarte.scrcpyforandroid.services.SnackbarController
|
||||
@@ -98,7 +96,6 @@ fun StreamScreen(activity: StreamActivity) {
|
||||
}
|
||||
val themeController = remember(themeMode) { ThemeController(colorSchemeMode = themeMode) }
|
||||
|
||||
val haptics = rememberAppHaptics()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val snackbarScope = rememberCoroutineScope()
|
||||
val snackbarController = remember(snackbarScope, snackbarHostState) {
|
||||
@@ -111,7 +108,6 @@ fun StreamScreen(activity: StreamActivity) {
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalSnackbarController provides snackbarController,
|
||||
LocalAppHaptics provides haptics,
|
||||
) {
|
||||
FullscreenControlScreen(
|
||||
scrcpy = scrcpy,
|
||||
|
||||
@@ -18,12 +18,16 @@ import androidx.compose.runtime.key
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.state.ToggleableState
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
|
||||
import io.github.miuzarte.scrcpyforandroid.haptics.LocalAppHaptics
|
||||
import io.github.miuzarte.scrcpyforandroid.ui.confirm
|
||||
import io.github.miuzarte.scrcpyforandroid.ui.contextClick
|
||||
import io.github.miuzarte.scrcpyforandroid.ui.longPress
|
||||
import io.github.miuzarte.scrcpyforandroid.ui.segmentTick
|
||||
import sh.calvin.reorderable.ReorderableColumn
|
||||
import sh.calvin.reorderable.ReorderableRow
|
||||
import top.yukonga.miuix.kmp.basic.Card
|
||||
@@ -66,7 +70,7 @@ class ReorderableList(
|
||||
|
||||
@Composable
|
||||
operator fun invoke() {
|
||||
val haptics = LocalAppHaptics.current
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val items = itemsProvider()
|
||||
when (orientation) {
|
||||
Orientation.Column -> {
|
||||
@@ -75,7 +79,7 @@ class ReorderableList(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(UiSpacing.Small),
|
||||
onSettle = onSettle,
|
||||
onMove = haptics.segmentTick,
|
||||
onMove = haptic::segmentTick,
|
||||
) { _, item, _ ->
|
||||
key(item.id) {
|
||||
ReorderableItem {
|
||||
@@ -136,17 +140,11 @@ class ReorderableList(
|
||||
}
|
||||
if (item.dragEnabled) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
haptics.contextClick()
|
||||
},
|
||||
onClick = haptic::contextClick,
|
||||
modifier = Modifier
|
||||
.draggableHandle(
|
||||
onDragStarted = {
|
||||
haptics.longPress()
|
||||
},
|
||||
onDragStopped = {
|
||||
haptics.confirm()
|
||||
},
|
||||
onDragStarted = { haptic.longPress() },
|
||||
onDragStopped = { haptic.confirm() },
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
@@ -170,7 +168,7 @@ class ReorderableList(
|
||||
modifier = modifier.fillMaxHeight(),
|
||||
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Small),
|
||||
onSettle = onSettle,
|
||||
onMove = haptics.segmentTick,
|
||||
onMove = haptic::segmentTick,
|
||||
) { _, item, _ ->
|
||||
key(item.id) {
|
||||
ReorderableItem {
|
||||
@@ -207,12 +205,8 @@ class ReorderableList(
|
||||
onClick = {},
|
||||
modifier = Modifier
|
||||
.draggableHandle(
|
||||
onDragStarted = {
|
||||
haptics.longPress()
|
||||
},
|
||||
onDragStopped = {
|
||||
haptics.confirm()
|
||||
},
|
||||
onDragStarted = { haptic.longPress() },
|
||||
onDragStopped = { haptic.confirm() },
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
|
||||
@@ -28,7 +28,7 @@ import top.yukonga.miuix.kmp.theme.MiuixTheme.textStyles
|
||||
@Composable
|
||||
fun SuperSlider(
|
||||
title: String,
|
||||
summary: String,
|
||||
summary: String? = null,
|
||||
value: Float,
|
||||
onValueChange: (Float) -> Unit,
|
||||
valueRange: ClosedFloatingPointRange<Float>,
|
||||
@@ -42,7 +42,7 @@ fun SuperSlider(
|
||||
displayFormatter: (Float) -> String = { it.toInt().toString() },
|
||||
displayText: String? = null,
|
||||
inputTitle: String = title,
|
||||
inputSummary: String = summary,
|
||||
inputSummary: String? = summary,
|
||||
inputLabel: String = unit,
|
||||
useLabelAsPlaceholder: Boolean = false,
|
||||
inputInitialValue: String = displayFormatter(value),
|
||||
@@ -110,7 +110,7 @@ fun SuperSlider(
|
||||
private fun SliderInputDialog(
|
||||
showDialog: Boolean,
|
||||
title: String,
|
||||
summary: String,
|
||||
summary: String? = null,
|
||||
label: String = "",
|
||||
useLabelAsPlaceholder: Boolean = false,
|
||||
initialValue: String,
|
||||
|
||||
@@ -55,8 +55,8 @@ fun SuperSpinner(
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val isDropdownExpanded = rememberSaveable { mutableStateOf(false) }
|
||||
val isHoldDown = remember { mutableStateOf(false) }
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
val currentHapticFeedback by rememberUpdatedState(hapticFeedback)
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val hapticLatest by rememberUpdatedState(haptic)
|
||||
|
||||
val itemsNotEmpty = items.isNotEmpty()
|
||||
val actualEnabled = enabled && itemsNotEmpty
|
||||
@@ -77,7 +77,7 @@ fun SuperSpinner(
|
||||
isDropdownExpanded.value = !isDropdownExpanded.value
|
||||
if (isDropdownExpanded.value) {
|
||||
isHoldDown.value = true
|
||||
currentHapticFeedback.performHapticFeedback(HapticFeedbackType.ContextClick)
|
||||
hapticLatest.performHapticFeedback(HapticFeedbackType.ContextClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,7 +116,7 @@ fun SuperSpinner(
|
||||
onDismiss = { isDropdownExpanded.value = false },
|
||||
onDismissFinished = { isHoldDown.value = false },
|
||||
maxHeight = maxHeight,
|
||||
hapticFeedback = hapticFeedback,
|
||||
hapticFeedback = haptic,
|
||||
spinnerColors = spinnerColors,
|
||||
renderInRootScaffold = renderInRootScaffold,
|
||||
onSelectedIndexChange = onSelectedIndexChange,
|
||||
@@ -145,12 +145,12 @@ private fun SuperSpinnerPopup(
|
||||
onSelectedIndexChange: ((Int) -> Unit)?,
|
||||
forceNoSelectedState: Boolean,
|
||||
) {
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val onSelectState = rememberUpdatedState(onSelectedIndexChange)
|
||||
val currentOnDismiss by rememberUpdatedState(onDismiss)
|
||||
val currentHapticFeedback by rememberUpdatedState(hapticFeedback)
|
||||
val onItemSelected: (Int) -> Unit = remember {
|
||||
{ selectedIdx ->
|
||||
currentHapticFeedback.performHapticFeedback(HapticFeedbackType.Confirm)
|
||||
haptic.performHapticFeedback(HapticFeedbackType.Confirm)
|
||||
onSelectState.value?.invoke(selectedIdx)
|
||||
currentOnDismiss()
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Orientation
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.OrientationLock
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Tick
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.VideoSource
|
||||
import io.github.miuzarte.scrcpyforandroid.services.NativeRecordingSupport
|
||||
|
||||
data class ClientOptions(
|
||||
// var serial: String = "", // to server
|
||||
@@ -137,8 +138,7 @@ data class ClientOptions(
|
||||
// --turn-screen-off
|
||||
var turnScreenOff: Boolean = false,
|
||||
|
||||
// [sc_key_inject_mode]
|
||||
// var keyInjectMode: KeyInjectMode,
|
||||
var keyInjectMode: KeyInjectMode = KeyInjectMode.MIXED,
|
||||
|
||||
// var windowBorderless: Boolean,
|
||||
// var mipmaps: Boolean,
|
||||
@@ -152,7 +152,7 @@ data class ClientOptions(
|
||||
// --disable-screensaver
|
||||
var disableScreensaver: Boolean = false,
|
||||
|
||||
// var forwardKeyRepeat: Boolean,
|
||||
var forwardKeyRepeat: Boolean = true,
|
||||
var legacyPaste: Boolean = false,
|
||||
|
||||
// --power-off-on-close
|
||||
@@ -209,15 +209,27 @@ data class ClientOptions(
|
||||
// --no-vd-system-decorations
|
||||
var vdSystemDecorations: Boolean = true, // to server
|
||||
) {
|
||||
enum class KeyInjectMode(val string: String) {
|
||||
MIXED("mixed"),
|
||||
PREFER_TEXT("prefer_text"),
|
||||
RAW("raw");
|
||||
|
||||
companion object {
|
||||
fun fromString(value: String) =
|
||||
entries.find { it.string.equals(value, ignoreCase = true) }
|
||||
?: MIXED
|
||||
}
|
||||
}
|
||||
|
||||
enum class RecordFormat(val string: String) {
|
||||
AUTO("auto"), // ignore
|
||||
MP4("mp4"),
|
||||
MKV("mkv"),
|
||||
MKV("mkv"), // not implemented
|
||||
M4A("m4a"),
|
||||
MKA("mka"),
|
||||
OPUS("opus"),
|
||||
MKA("mka"), // not implemented
|
||||
OPUS("opus"), // not implemented
|
||||
AAC("aac"),
|
||||
FLAC("flac"),
|
||||
FLAC("flac"), // not implemented
|
||||
WAV("wav");
|
||||
|
||||
fun isAudioOnly(): Boolean = when (this) {
|
||||
@@ -229,6 +241,11 @@ data class ClientOptions(
|
||||
fun fromString(value: String) =
|
||||
entries.find { it.string.equals(value, ignoreCase = true) }
|
||||
?: AUTO
|
||||
|
||||
fun guessFromFilename(filename: String): RecordFormat {
|
||||
val extension = filename.substringAfterLast('.', "").trim()
|
||||
return fromString(extension)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,7 +346,7 @@ data class ClientOptions(
|
||||
}
|
||||
}
|
||||
|
||||
if (cameraHighSpeed && cameraFps > 0u) {
|
||||
if (cameraHighSpeed && cameraFps <= 0u) {
|
||||
throw IllegalArgumentException(
|
||||
"--camera-high-speed requires an explicit --camera-fps value"
|
||||
)
|
||||
@@ -366,15 +383,16 @@ data class ClientOptions(
|
||||
|
||||
if (audio && audioSource == AudioSource.AUTO) {
|
||||
// Select the audio source according to the video source
|
||||
audioSource = if (videoSource == VideoSource.DISPLAY) {
|
||||
if (audioDup) {
|
||||
AudioSource.PLAYBACK
|
||||
audioSource =
|
||||
if (videoSource == VideoSource.DISPLAY) {
|
||||
if (audioDup) {
|
||||
AudioSource.PLAYBACK
|
||||
} else {
|
||||
AudioSource.OUTPUT
|
||||
}
|
||||
} else {
|
||||
AudioSource.OUTPUT
|
||||
AudioSource.MIC
|
||||
}
|
||||
} else {
|
||||
AudioSource.MIC
|
||||
}
|
||||
}
|
||||
|
||||
if (audioDup) {
|
||||
@@ -391,7 +409,7 @@ data class ClientOptions(
|
||||
}
|
||||
}
|
||||
|
||||
if (recordFormat != RecordFormat.AUTO && recordFilename.isNotBlank()) {
|
||||
if (recordFormat != RecordFormat.AUTO && recordFilename.isBlank()) {
|
||||
throw IllegalArgumentException(
|
||||
"Record format specified without recording"
|
||||
)
|
||||
@@ -405,8 +423,19 @@ data class ClientOptions(
|
||||
}
|
||||
|
||||
if (recordFormat == RecordFormat.AUTO) {
|
||||
// TODO: guess from recordFilename
|
||||
recordFormat = RecordFormat.MP4
|
||||
recordFormat = RecordFormat.guessFromFilename(recordFilename)
|
||||
if (recordFormat == RecordFormat.AUTO) {
|
||||
throw IllegalArgumentException(
|
||||
"No format specified for recording file " +
|
||||
"(try with --record-format=mp4)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!NativeRecordingSupport.isSupported(recordFormat)) {
|
||||
throw IllegalArgumentException(
|
||||
"Android native recording currently supports only MP4/M4A"
|
||||
)
|
||||
}
|
||||
|
||||
if (recordOrientation != Orientation.ORIENT_0) {
|
||||
@@ -424,6 +453,8 @@ data class ClientOptions(
|
||||
)
|
||||
}
|
||||
|
||||
/*
|
||||
// 录制用的不是 muxer,以下判断无意义
|
||||
if (recordFormat == RecordFormat.OPUS && audioCodec != Codec.OPUS) {
|
||||
throw IllegalArgumentException(
|
||||
"Recording to OPUS file requires an OPUS audio stream " +
|
||||
@@ -459,6 +490,7 @@ data class ClientOptions(
|
||||
"Recording to MP4 container does not support RAW audio"
|
||||
)
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -563,8 +595,6 @@ data class ClientOptions(
|
||||
cleanUp = cleanup,
|
||||
powerOn = powerOn,
|
||||
|
||||
// killAdbOnClose == killAdbOnClose, // client side
|
||||
|
||||
cameraHighSpeed = cameraHighSpeed,
|
||||
vdDestroyContent = vdDestroyContent,
|
||||
vdSystemDecorations = vdSystemDecorations,
|
||||
|
||||
@@ -15,8 +15,13 @@ import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.CameraFacing
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.EncoderType
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.ListOptions
|
||||
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
|
||||
import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent
|
||||
import io.github.miuzarte.scrcpyforandroid.services.LocalInputService
|
||||
import io.github.miuzarte.scrcpyforandroid.services.NativeAacRecorder
|
||||
import io.github.miuzarte.scrcpyforandroid.services.NativeMp4Recorder
|
||||
import io.github.miuzarte.scrcpyforandroid.services.NativeWavRecorder
|
||||
import io.github.miuzarte.scrcpyforandroid.services.RecordingFileResolver
|
||||
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -94,6 +99,12 @@ class Scrcpy(
|
||||
|
||||
@Volatile
|
||||
private var audioPlayer: ScrcpyAudioPlayer? = null
|
||||
@Volatile
|
||||
private var mp4Recorder: NativeMp4Recorder? = null
|
||||
@Volatile
|
||||
private var wavRecorder: NativeWavRecorder? = null
|
||||
@Volatile
|
||||
private var aacRecorder: NativeAacRecorder? = null
|
||||
|
||||
val listings = Listings()
|
||||
|
||||
@@ -149,11 +160,16 @@ class Scrcpy(
|
||||
}
|
||||
|
||||
// Execute server
|
||||
val info = executeServer(
|
||||
val rawInfo = executeServer(
|
||||
serverJar = serverJar,
|
||||
options = options,
|
||||
scid = scid,
|
||||
)
|
||||
val currentTarget = AppRuntime.currentConnectionTarget
|
||||
val info = rawInfo.copy(
|
||||
host = currentTarget?.host ?: "",
|
||||
port = currentTarget?.port ?: Defaults.ADB_PORT,
|
||||
)
|
||||
|
||||
// Turn screen off if requested
|
||||
if (options.turnScreenOff) {
|
||||
@@ -170,6 +186,10 @@ class Scrcpy(
|
||||
legacyPaste = options.legacyPaste,
|
||||
mouseHover = options.mouseHover,
|
||||
killAdbOnClose = options.killAdbOnClose,
|
||||
videoPlayback = options.videoPlayback,
|
||||
audioPlayback = options.audioPlayback,
|
||||
keyInjectMode = options.keyInjectMode,
|
||||
forwardKeyRepeat = options.forwardKeyRepeat,
|
||||
)
|
||||
isRunning = true
|
||||
startClipboardSync()
|
||||
@@ -182,6 +202,12 @@ class Scrcpy(
|
||||
// Setup audio player
|
||||
audioPlayer?.release()
|
||||
audioPlayer = null
|
||||
mp4Recorder?.release()
|
||||
mp4Recorder = null
|
||||
wavRecorder?.release()
|
||||
wavRecorder = null
|
||||
aacRecorder?.release()
|
||||
aacRecorder = null
|
||||
if (info.audioCodecId != 0 && options.audioPlayback) {
|
||||
Log.i(
|
||||
TAG,
|
||||
@@ -198,6 +224,63 @@ class Scrcpy(
|
||||
Log.i(TAG, "start(): audio playback disabled for this session")
|
||||
}
|
||||
|
||||
if (options.recordFilename.isNotBlank()) {
|
||||
val recordFile = RecordingFileResolver.resolve(options, info)
|
||||
when (options.recordFormat) {
|
||||
ClientOptions.RecordFormat.MP4,
|
||||
ClientOptions.RecordFormat.M4A,
|
||||
-> {
|
||||
val recorder = NativeMp4Recorder(
|
||||
outputFile = recordFile,
|
||||
includeVideo = options.video,
|
||||
includeAudio = options.audio && info.audioCodec != null,
|
||||
inputAudioCodec = info.audioCodec,
|
||||
width = info.width,
|
||||
height = info.height,
|
||||
videoBitRate = options.videoBitRate,
|
||||
audioBitRate = options.audioBitRate,
|
||||
)
|
||||
recorder.start()
|
||||
mp4Recorder = recorder
|
||||
if (options.audio && info.audioCodec != null) {
|
||||
session.attachAudioConsumer { packet ->
|
||||
recorder.feedAudioPacket(packet.data, packet.ptsUs, packet.isConfig)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ClientOptions.RecordFormat.WAV -> {
|
||||
val codec = info.audioCodec
|
||||
?: throw IllegalStateException("WAV recording requires an audio stream")
|
||||
val recorder = NativeWavRecorder(
|
||||
outputFile = recordFile,
|
||||
inputCodec = codec,
|
||||
)
|
||||
wavRecorder = recorder
|
||||
session.attachAudioConsumer { packet ->
|
||||
recorder.feedPacket(packet.data, packet.ptsUs, packet.isConfig)
|
||||
}
|
||||
}
|
||||
|
||||
ClientOptions.RecordFormat.AAC -> {
|
||||
val codec = info.audioCodec
|
||||
?: throw IllegalStateException("AAC recording requires an audio stream")
|
||||
val recorder = NativeAacRecorder(
|
||||
outputFile = recordFile,
|
||||
inputCodec = codec,
|
||||
bitRate = options.audioBitRate,
|
||||
)
|
||||
aacRecorder = recorder
|
||||
session.attachAudioConsumer { packet ->
|
||||
recorder.feedPacket(packet.data, packet.ptsUs, packet.isConfig)
|
||||
}
|
||||
}
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
Log.i(TAG, "start(): recording -> ${recordFile.absolutePath}")
|
||||
}
|
||||
|
||||
Log.i(
|
||||
TAG, "start(): Session started successfully - device=${info.deviceName}, " +
|
||||
"video=${if (options.video) "${info.codec?.string ?: "null"} ${info.width}x${info.height}" else "off"}, " +
|
||||
@@ -209,6 +292,14 @@ class Scrcpy(
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "start(): Failed to start scrcpy session", e)
|
||||
audioPlayer?.release()
|
||||
audioPlayer = null
|
||||
runCatching { mp4Recorder?.release() }
|
||||
mp4Recorder = null
|
||||
runCatching { wavRecorder?.release() }
|
||||
wavRecorder = null
|
||||
runCatching { aacRecorder?.release() }
|
||||
aacRecorder = null
|
||||
isRunning = false
|
||||
_currentSessionState.value = null
|
||||
throw e
|
||||
@@ -224,9 +315,15 @@ class Scrcpy(
|
||||
Log.i(TAG, "stop(): Stopping scrcpy session")
|
||||
|
||||
return@withContext try {
|
||||
NativeCoreFacade.onScrcpySessionStopped()
|
||||
session.clearVideoConsumer()
|
||||
session.clearAudioConsumer()
|
||||
mp4Recorder?.release()
|
||||
mp4Recorder = null
|
||||
wavRecorder?.release()
|
||||
wavRecorder = null
|
||||
aacRecorder?.release()
|
||||
aacRecorder = null
|
||||
NativeCoreFacade.onScrcpySessionStopped()
|
||||
session.stop()
|
||||
audioPlayer?.release()
|
||||
audioPlayer = null
|
||||
@@ -243,23 +340,31 @@ class Scrcpy(
|
||||
|
||||
fun isStarted(): Boolean = isRunning && session.isStarted()
|
||||
|
||||
suspend fun startApp(name: String) = session.startApp(name)
|
||||
suspend fun startApp(name: String) = withContext(Dispatchers.IO) {
|
||||
session.startApp(name)
|
||||
}
|
||||
|
||||
suspend fun injectKeycode(
|
||||
action: Int,
|
||||
keycode: Int,
|
||||
repeat: Int = 0,
|
||||
metaState: Int = 0,
|
||||
) = session.injectKeycode(
|
||||
action = action,
|
||||
keycode = keycode,
|
||||
repeat = repeat,
|
||||
metaState = metaState,
|
||||
)
|
||||
) = withContext(Dispatchers.IO) {
|
||||
session.injectKeycode(
|
||||
action = action,
|
||||
keycode = keycode,
|
||||
repeat = repeat,
|
||||
metaState = metaState,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun injectText(text: String) = session.injectText(text)
|
||||
suspend fun injectText(text: String) = withContext(Dispatchers.IO) {
|
||||
session.injectText(text)
|
||||
}
|
||||
|
||||
suspend fun setClipboard(text: String, paste: Boolean) = session.setClipboard(text, paste)
|
||||
suspend fun setClipboard(text: String, paste: Boolean) = withContext(Dispatchers.IO) {
|
||||
session.setClipboard(text, paste)
|
||||
}
|
||||
|
||||
suspend fun injectTouch(
|
||||
action: Int,
|
||||
@@ -271,17 +376,19 @@ class Scrcpy(
|
||||
pressure: Float,
|
||||
actionButton: Int = 0,
|
||||
buttons: Int = 0,
|
||||
) = session.injectTouch(
|
||||
action = action,
|
||||
pointerId = pointerId,
|
||||
x = x,
|
||||
y = y,
|
||||
screenWidth = screenWidth,
|
||||
screenHeight = screenHeight,
|
||||
pressure = pressure,
|
||||
actionButton = actionButton,
|
||||
buttons = buttons,
|
||||
)
|
||||
) = withContext(Dispatchers.IO) {
|
||||
session.injectTouch(
|
||||
action = action,
|
||||
pointerId = pointerId,
|
||||
x = x,
|
||||
y = y,
|
||||
screenWidth = screenWidth,
|
||||
screenHeight = screenHeight,
|
||||
pressure = pressure,
|
||||
actionButton = actionButton,
|
||||
buttons = buttons,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun injectScroll(
|
||||
x: Int,
|
||||
@@ -291,18 +398,22 @@ class Scrcpy(
|
||||
hScroll: Float,
|
||||
vScroll: Float,
|
||||
buttons: Int,
|
||||
) = session.injectScroll(
|
||||
x = x,
|
||||
y = y,
|
||||
screenWidth = screenWidth,
|
||||
screenHeight = screenHeight,
|
||||
hScroll = hScroll,
|
||||
vScroll = vScroll,
|
||||
buttons = buttons,
|
||||
)
|
||||
) = withContext(Dispatchers.IO) {
|
||||
session.injectScroll(
|
||||
x = x,
|
||||
y = y,
|
||||
screenWidth = screenWidth,
|
||||
screenHeight = screenHeight,
|
||||
hScroll = hScroll,
|
||||
vScroll = vScroll,
|
||||
buttons = buttons,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun pressBackOrTurnScreenOn(action: Int = KeyEvent.ACTION_DOWN) =
|
||||
session.pressBackOrTurnScreenOn(action)
|
||||
withContext(Dispatchers.IO) {
|
||||
session.pressBackOrTurnScreenOn(action)
|
||||
}
|
||||
|
||||
fun updateCurrentSessionSize(width: Int, height: Int) {
|
||||
val current = _currentSessionState.value ?: return
|
||||
@@ -815,14 +926,12 @@ class Scrcpy(
|
||||
@Volatile
|
||||
private var activeSession: ActiveSession? = null
|
||||
|
||||
@Volatile
|
||||
private var videoConsumer: ((VideoPacket) -> Unit)? = null
|
||||
private val videoConsumers = linkedSetOf<(VideoPacket) -> Unit>()
|
||||
|
||||
@Volatile
|
||||
private var videoReaderThread: Thread? = null
|
||||
|
||||
@Volatile
|
||||
private var audioConsumer: ((AudioPacket) -> Unit)? = null
|
||||
private val audioConsumers = linkedSetOf<(AudioPacket) -> Unit>()
|
||||
|
||||
@Volatile
|
||||
private var audioReaderThread: Thread? = null
|
||||
@@ -947,6 +1056,7 @@ class Scrcpy(
|
||||
width = width,
|
||||
height = height,
|
||||
audioCodecId = audioCodecId,
|
||||
audioCodec = Codec.fromId(audioCodecId, Codec.Type.AUDIO),
|
||||
controlEnabled = controlStream != null,
|
||||
)
|
||||
|
||||
@@ -987,7 +1097,7 @@ class Scrcpy(
|
||||
val session = activeSession ?: throw IllegalStateException("scrcpy session not started")
|
||||
val vInput = session.videoInput ?: return
|
||||
val vStream = session.videoStream ?: return
|
||||
videoConsumer = consumer
|
||||
videoConsumers += consumer
|
||||
if (videoReaderThread?.isAlive == true) {
|
||||
return
|
||||
}
|
||||
@@ -1008,14 +1118,13 @@ class Scrcpy(
|
||||
val config = (ptsAndFlags and PACKET_FLAG_CONFIG) != 0L
|
||||
val keyFrame = (ptsAndFlags and PACKET_FLAG_KEY_FRAME) != 0L
|
||||
val ptsUs = ptsAndFlags and PACKET_PTS_MASK
|
||||
videoConsumer?.invoke(
|
||||
VideoPacket(
|
||||
data = payload,
|
||||
ptsUs = ptsUs,
|
||||
isConfig = config,
|
||||
isKeyFrame = keyFrame,
|
||||
),
|
||||
val packet = VideoPacket(
|
||||
data = payload,
|
||||
ptsUs = ptsUs,
|
||||
isConfig = config,
|
||||
isKeyFrame = keyFrame,
|
||||
)
|
||||
videoConsumers.forEach { it(packet) }
|
||||
} catch (_: EOFException) {
|
||||
break
|
||||
} catch (_: InterruptedException) {
|
||||
@@ -1033,13 +1142,13 @@ class Scrcpy(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearVideoConsumer() = mutex.withLock { videoConsumer = null }
|
||||
suspend fun clearVideoConsumer() = mutex.withLock { videoConsumers.clear() }
|
||||
|
||||
suspend fun attachAudioConsumer(consumer: (AudioPacket) -> Unit): Unit = mutex.withLock {
|
||||
val session = activeSession ?: throw IllegalStateException("scrcpy session not started")
|
||||
val aInput = session.audioInput ?: return
|
||||
val aStream = session.audioStream ?: return
|
||||
audioConsumer = consumer
|
||||
audioConsumers += consumer
|
||||
if (audioReaderThread?.isAlive == true) return
|
||||
|
||||
audioReaderThread = thread(start = true, name = "scrcpy-audio-reader") {
|
||||
@@ -1055,13 +1164,12 @@ class Scrcpy(
|
||||
|
||||
val isConfig = (ptsAndFlags and PACKET_FLAG_CONFIG) != 0L
|
||||
val ptsUs = ptsAndFlags and PACKET_PTS_MASK
|
||||
audioConsumer?.invoke(
|
||||
AudioPacket(
|
||||
data = payload,
|
||||
ptsUs = ptsUs,
|
||||
isConfig = isConfig
|
||||
)
|
||||
val packet = AudioPacket(
|
||||
data = payload,
|
||||
ptsUs = ptsUs,
|
||||
isConfig = isConfig
|
||||
)
|
||||
audioConsumers.forEach { it(packet) }
|
||||
} catch (_: EOFException) {
|
||||
break
|
||||
} catch (_: InterruptedException) {
|
||||
@@ -1079,7 +1187,7 @@ class Scrcpy(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearAudioConsumer() = mutex.withLock { audioConsumer = null }
|
||||
suspend fun clearAudioConsumer() = mutex.withLock { audioConsumers.clear() }
|
||||
|
||||
suspend fun startApp(name: String) = mutex.withLock {
|
||||
try {
|
||||
@@ -1194,8 +1302,8 @@ class Scrcpy(
|
||||
private fun stopInternal() {
|
||||
val session = activeSession ?: return
|
||||
activeSession = null
|
||||
videoConsumer = null
|
||||
audioConsumer = null
|
||||
videoConsumers.clear()
|
||||
audioConsumers.clear()
|
||||
|
||||
if (Thread.currentThread() !== videoReaderThread) {
|
||||
runCatching { videoReaderThread?.interrupt() }
|
||||
@@ -1367,10 +1475,15 @@ class Scrcpy(
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
val audioCodecId: Int = 0,
|
||||
val audioCodec: Codec? = null,
|
||||
val controlEnabled: Boolean,
|
||||
val legacyPaste: Boolean = false,
|
||||
val mouseHover: Boolean = true,
|
||||
val killAdbOnClose: Boolean = false,
|
||||
val videoPlayback: Boolean = true,
|
||||
val audioPlayback: Boolean = true,
|
||||
val keyInjectMode: ClientOptions.KeyInjectMode = ClientOptions.KeyInjectMode.MIXED,
|
||||
val forwardKeyRepeat: Boolean = true,
|
||||
val host: String = "",
|
||||
val port: Int = Defaults.ADB_PORT,
|
||||
)
|
||||
|
||||
@@ -19,4 +19,5 @@ object AppRuntime {
|
||||
|
||||
var scrcpy: Scrcpy? = null
|
||||
var currentConnectionTarget: ConnectionTarget? = null
|
||||
var currentConnectedDevice: ConnectedDeviceInfo? = null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
package io.github.miuzarte.scrcpyforandroid.services
|
||||
|
||||
import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
||||
import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions
|
||||
|
||||
internal data class ConnectionDisconnectResult(
|
||||
val clearedTarget: ConnectionTarget?,
|
||||
)
|
||||
|
||||
internal data class ConnectedAdbResult(
|
||||
val target: ConnectionTarget,
|
||||
val info: ConnectedDeviceInfo,
|
||||
val scrcpyProfileId: String,
|
||||
)
|
||||
|
||||
internal data class StopScrcpyResult(
|
||||
val disconnectedAdb: Boolean,
|
||||
val clearedTarget: ConnectionTarget?,
|
||||
)
|
||||
|
||||
internal class ConnectionController(
|
||||
private val scrcpy: Scrcpy,
|
||||
private val stateStore: ConnectionStateStore,
|
||||
private val adbCoordinator: DeviceAdbConnectionCoordinator = DeviceAdbConnectionCoordinator(),
|
||||
) {
|
||||
val state: ConnectionState
|
||||
get() = stateStore.state.value
|
||||
|
||||
suspend fun disconnectAdbConnection(
|
||||
clearQuickOnlineForTarget: ConnectionTarget? = state.adbSession.currentTarget,
|
||||
cause: DisconnectCause = DisconnectCause.Unknown,
|
||||
statusLine: String = "未连接",
|
||||
): ConnectionDisconnectResult {
|
||||
stateStore.markDisconnected(cause = cause, statusLine = statusLine)
|
||||
AppRuntime.currentConnectionTarget = null
|
||||
AppRuntime.currentConnectedDevice = null
|
||||
runCatching { scrcpy.stop() }
|
||||
runCatching { adbCoordinator.disconnect() }
|
||||
AppScreenOn.release()
|
||||
return ConnectionDisconnectResult(clearedTarget = clearQuickOnlineForTarget)
|
||||
}
|
||||
|
||||
suspend fun disconnectCurrentTargetBeforeConnecting(
|
||||
newHost: String,
|
||||
newPort: Int,
|
||||
): ConnectionTarget? {
|
||||
val current = state.adbSession.currentTarget ?: return null
|
||||
if (!state.adbSession.isConnected) return null
|
||||
if (current.host == newHost && current.port == newPort) return null
|
||||
|
||||
disconnectAdbConnection(
|
||||
clearQuickOnlineForTarget = current,
|
||||
cause = DisconnectCause.SwitchTarget,
|
||||
)
|
||||
return current
|
||||
}
|
||||
|
||||
fun applyConnectedDeviceCapabilities(sdkInt: Int) {
|
||||
stateStore.updateSession {
|
||||
it.copy(
|
||||
audioForwardingSupported = sdkInt !in 0..<30,
|
||||
cameraMirroringSupported = sdkInt !in 0..<31,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun connectWithTimeout(host: String, port: Int, timeoutMs: Long) {
|
||||
adbCoordinator.connectWithTimeout(host, port, timeoutMs)
|
||||
}
|
||||
|
||||
suspend fun keepAliveCheck(timeoutMs: Long): Boolean {
|
||||
return adbCoordinator.isConnected(timeoutMs)
|
||||
}
|
||||
|
||||
suspend fun probeTcpReachable(host: String, port: Int, timeoutMs: Int): Boolean {
|
||||
return adbCoordinator.probeTcpReachable(host, port, timeoutMs)
|
||||
}
|
||||
|
||||
suspend fun runAutoAdbConnect(
|
||||
host: String,
|
||||
port: Int,
|
||||
timeoutMs: Long,
|
||||
): Boolean {
|
||||
return runCatching {
|
||||
connectWithTimeout(host, port, timeoutMs)
|
||||
true
|
||||
}.getOrElse { error ->
|
||||
stateStore.update {
|
||||
it.copy(
|
||||
disconnectCause = DisconnectCause.AutoReconnectFailed,
|
||||
lastError = error.message,
|
||||
)
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun handleAdbConnected(
|
||||
host: String,
|
||||
port: Int,
|
||||
scrcpyProfileId: String = ScrcpyOptions.GLOBAL_PROFILE_ID,
|
||||
): ConnectedAdbResult {
|
||||
val target = ConnectionTarget(host, port)
|
||||
stateStore.markConnected(target = target, scrcpyProfileId = scrcpyProfileId)
|
||||
AppRuntime.currentConnectionTarget = target
|
||||
|
||||
val info = adbCoordinator.fetchConnectedDeviceInfo(host, port)
|
||||
stateStore.updateSession {
|
||||
it.copy(connectedDeviceLabel = info.model)
|
||||
}
|
||||
AppRuntime.currentConnectedDevice = info
|
||||
applyConnectedDeviceCapabilities(info.sdkInt)
|
||||
return ConnectedAdbResult(
|
||||
target = target,
|
||||
info = info,
|
||||
scrcpyProfileId = scrcpyProfileId,
|
||||
)
|
||||
}
|
||||
|
||||
fun syncConnectedScrcpyProfileId(profileId: String) {
|
||||
stateStore.updateSession {
|
||||
it.copy(connectedScrcpyProfileId = profileId)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateQuickConnected(quickConnected: Boolean) {
|
||||
stateStore.updateSession {
|
||||
it.copy(isQuickConnected = quickConnected)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateStatusLine(statusLine: String) {
|
||||
stateStore.updateSession {
|
||||
it.copy(statusLine = statusLine)
|
||||
}
|
||||
}
|
||||
|
||||
fun markConnectionFailed(error: Throwable) {
|
||||
stateStore.markConnectionFailed(error.message)
|
||||
}
|
||||
|
||||
fun markKeepAliveReconnectSuccess(host: String, port: Int) {
|
||||
stateStore.update {
|
||||
it.copy(
|
||||
adbSession = it.adbSession.copy(
|
||||
isConnected = true,
|
||||
statusLine = "$host:$port",
|
||||
),
|
||||
disconnectCause = null,
|
||||
lastError = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun markScrcpyStarted() {
|
||||
stateStore.updateSession {
|
||||
it.copy(statusLine = "scrcpy 运行中")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun stopScrcpySession(killAdbOnClose: Boolean): StopScrcpyResult {
|
||||
scrcpy.stop()
|
||||
if (killAdbOnClose) {
|
||||
val disconnected = disconnectAdbConnection(
|
||||
clearQuickOnlineForTarget = state.adbSession.currentTarget,
|
||||
cause = DisconnectCause.KillAdbOnClose,
|
||||
)
|
||||
return StopScrcpyResult(
|
||||
disconnectedAdb = true,
|
||||
clearedTarget = disconnected.clearedTarget,
|
||||
)
|
||||
}
|
||||
|
||||
AppScreenOn.release()
|
||||
val target = state.adbSession.currentTarget
|
||||
if (target != null) {
|
||||
updateStatusLine("${target.host}:${target.port}")
|
||||
}
|
||||
return StopScrcpyResult(
|
||||
disconnectedAdb = false,
|
||||
clearedTarget = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package io.github.miuzarte.scrcpyforandroid.services
|
||||
|
||||
import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget
|
||||
import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
internal enum class DisconnectCause {
|
||||
User,
|
||||
KeepAliveFailed,
|
||||
SwitchTarget,
|
||||
KillAdbOnClose,
|
||||
ConnectFailed,
|
||||
AutoReconnectFailed,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
internal data class ConnectionState(
|
||||
val adbSession: DeviceAdbSessionState = DeviceAdbSessionState(),
|
||||
val disconnectCause: DisconnectCause? = null,
|
||||
val lastError: String? = null,
|
||||
)
|
||||
|
||||
internal class ConnectionStateStore {
|
||||
private val _state = MutableStateFlow(ConnectionState())
|
||||
val state: StateFlow<ConnectionState> = _state.asStateFlow()
|
||||
|
||||
fun update(transform: (ConnectionState) -> ConnectionState) {
|
||||
_state.update(transform)
|
||||
}
|
||||
|
||||
fun updateSession(transform: (DeviceAdbSessionState) -> DeviceAdbSessionState) {
|
||||
_state.update { current ->
|
||||
current.copy(adbSession = transform(current.adbSession))
|
||||
}
|
||||
}
|
||||
|
||||
fun markConnected(
|
||||
target: ConnectionTarget,
|
||||
scrcpyProfileId: String = ScrcpyOptions.GLOBAL_PROFILE_ID,
|
||||
) {
|
||||
update {
|
||||
it.copy(
|
||||
adbSession = it.adbSession.copy(
|
||||
isConnected = true,
|
||||
currentTarget = target,
|
||||
connectedScrcpyProfileId = scrcpyProfileId,
|
||||
statusLine = "${target.host}:${target.port}",
|
||||
),
|
||||
disconnectCause = null,
|
||||
lastError = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun markDisconnected(
|
||||
cause: DisconnectCause,
|
||||
statusLine: String = "未连接",
|
||||
connectedDeviceLabel: String = "未连接",
|
||||
) {
|
||||
update {
|
||||
it.copy(
|
||||
adbSession = DeviceAdbSessionState(
|
||||
statusLine = statusLine,
|
||||
connectedDeviceLabel = connectedDeviceLabel,
|
||||
),
|
||||
disconnectCause = cause,
|
||||
lastError = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun markConnectionFailed(message: String?) {
|
||||
update {
|
||||
it.copy(
|
||||
adbSession = it.adbSession.copy(statusLine = "ADB 连接失败"),
|
||||
disconnectCause = DisconnectCause.ConnectFailed,
|
||||
lastError = message,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package io.github.miuzarte.scrcpyforandroid.services
|
||||
|
||||
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut
|
||||
import java.io.Closeable
|
||||
|
||||
internal class DeviceAdbAutoReconnectManager(
|
||||
private val controller: ConnectionController,
|
||||
private val stateStore: ConnectionStateStore,
|
||||
private val backgroundRunner: DeviceAdbBackgroundRunner = DeviceAdbBackgroundRunner(),
|
||||
) : Closeable {
|
||||
suspend fun runKeepAliveLoop(
|
||||
isForeground: () -> Boolean,
|
||||
intervalMs: Long,
|
||||
connectTimeoutMs: Long,
|
||||
keepAliveTimeoutMs: Long,
|
||||
onReconnectSuccess: suspend (host: String, port: Int) -> Unit,
|
||||
onReconnectFailure: suspend (Throwable) -> Unit,
|
||||
) {
|
||||
backgroundRunner.runKeepAliveLoop(
|
||||
sessionState = { stateStore.state.value.adbSession },
|
||||
isForeground = isForeground,
|
||||
intervalMs = intervalMs,
|
||||
keepAliveCheck = { _, _ -> controller.keepAliveCheck(keepAliveTimeoutMs) },
|
||||
reconnect = { host, port -> controller.connectWithTimeout(host, port, connectTimeoutMs) },
|
||||
onReconnectSuccess = { host, port ->
|
||||
controller.markKeepAliveReconnectSuccess(host, port)
|
||||
onReconnectSuccess(host, port)
|
||||
},
|
||||
onReconnectFailure = onReconnectFailure,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun runAutoReconnectLoop(
|
||||
isForeground: () -> Boolean,
|
||||
isAutoReconnectEnabled: () -> Boolean,
|
||||
isBusy: () -> Boolean,
|
||||
isAdbConnecting: () -> Boolean,
|
||||
hasActiveSession: () -> Boolean,
|
||||
savedShortcuts: () -> List<DeviceShortcut>,
|
||||
isBlacklisted: (String) -> Boolean,
|
||||
connectTimeoutMs: Long,
|
||||
probeTimeoutMs: Int,
|
||||
discoverConnectService: suspend () -> Pair<String, Int>?,
|
||||
onMdnsPortChanged: suspend (host: String, oldPort: Int, newPort: Int) -> Unit,
|
||||
onKnownDeviceReconnected: suspend (DeviceShortcut) -> Unit,
|
||||
onDiscoveredDeviceReconnected: suspend (host: String, port: Int, knownDevice: DeviceShortcut) -> Unit,
|
||||
retryIntervalMs: Long,
|
||||
) {
|
||||
backgroundRunner.runAutoReconnectLoop(
|
||||
isConnected = { stateStore.state.value.adbSession.isConnected },
|
||||
isForeground = isForeground,
|
||||
isAutoReconnectEnabled = {
|
||||
isAutoReconnectEnabled() &&
|
||||
stateStore.state.value.disconnectCause != DisconnectCause.User &&
|
||||
stateStore.state.value.disconnectCause != DisconnectCause.KillAdbOnClose
|
||||
},
|
||||
isBusy = isBusy,
|
||||
isAdbConnecting = isAdbConnecting,
|
||||
hasActiveSession = hasActiveSession,
|
||||
savedShortcuts = savedShortcuts,
|
||||
isBlacklisted = isBlacklisted,
|
||||
probeTcpReachable = { host, port ->
|
||||
controller.probeTcpReachable(host, port, probeTimeoutMs)
|
||||
},
|
||||
discoverConnectService = discoverConnectService,
|
||||
onMdnsPortChanged = onMdnsPortChanged,
|
||||
connectKnownShortcut = { target ->
|
||||
if (!controller.runAutoAdbConnect(target.host, target.port, connectTimeoutMs)) {
|
||||
false
|
||||
} else {
|
||||
controller.handleAdbConnected(
|
||||
host = target.host,
|
||||
port = target.port,
|
||||
scrcpyProfileId = target.scrcpyProfileId,
|
||||
)
|
||||
onKnownDeviceReconnected(target)
|
||||
true
|
||||
}
|
||||
},
|
||||
connectDiscoveredShortcut = { host, port, knownDevice ->
|
||||
if (!controller.runAutoAdbConnect(host, port, connectTimeoutMs)) {
|
||||
false
|
||||
} else {
|
||||
controller.handleAdbConnected(
|
||||
host = host,
|
||||
port = port,
|
||||
scrcpyProfileId = knownDevice.scrcpyProfileId,
|
||||
)
|
||||
onDiscoveredDeviceReconnected(host, port, knownDevice)
|
||||
true
|
||||
}
|
||||
},
|
||||
retryIntervalMs = retryIntervalMs,
|
||||
)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
backgroundRunner.close()
|
||||
}
|
||||
}
|
||||
@@ -27,18 +27,24 @@ internal class DeviceAdbConnectionCoordinator(
|
||||
private val adbService: NativeAdbService = NativeAdbService,
|
||||
) {
|
||||
suspend fun connectWithTimeout(host: String, port: Int, timeoutMs: Long) {
|
||||
withTimeout(timeoutMs) {
|
||||
adbService.connect(host, port)
|
||||
withContext(Dispatchers.IO) {
|
||||
withTimeout(timeoutMs) {
|
||||
adbService.connect(host, port)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun disconnect() {
|
||||
adbService.disconnect()
|
||||
withContext(Dispatchers.IO) {
|
||||
adbService.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun isConnected(timeoutMs: Long): Boolean {
|
||||
return withTimeout(timeoutMs) {
|
||||
adbService.isConnected()
|
||||
return withContext(Dispatchers.IO) {
|
||||
withTimeout(timeoutMs) {
|
||||
adbService.isConnected()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,24 +67,30 @@ internal class DeviceAdbConnectionCoordinator(
|
||||
timeoutMs: Long = 12_000,
|
||||
includeLanDevices: Boolean = true,
|
||||
): Pair<String, Int>? {
|
||||
return adbService.discoverPairingService(
|
||||
timeoutMs = timeoutMs,
|
||||
includeLanDevices = includeLanDevices,
|
||||
)
|
||||
return withContext(Dispatchers.IO) {
|
||||
adbService.discoverPairingService(
|
||||
timeoutMs = timeoutMs,
|
||||
includeLanDevices = includeLanDevices,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun discoverConnectService(
|
||||
timeoutMs: Long = 12_000,
|
||||
includeLanDevices: Boolean = true,
|
||||
): Pair<String, Int>? {
|
||||
return adbService.discoverConnectService(
|
||||
timeoutMs = timeoutMs,
|
||||
includeLanDevices = includeLanDevices,
|
||||
)
|
||||
return withContext(Dispatchers.IO) {
|
||||
adbService.discoverConnectService(
|
||||
timeoutMs = timeoutMs,
|
||||
includeLanDevices = includeLanDevices,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun pair(host: String, port: Int, pairingCode: String): Boolean {
|
||||
return adbService.pair(host, port, pairingCode)
|
||||
return withContext(Dispatchers.IO) {
|
||||
adbService.pair(host, port, pairingCode)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun startApp(
|
||||
@@ -86,10 +98,12 @@ internal class DeviceAdbConnectionCoordinator(
|
||||
displayId: Int? = null,
|
||||
forceStop: Boolean = false,
|
||||
): String {
|
||||
return adbService.startApp(
|
||||
packageName = packageName,
|
||||
displayId = displayId,
|
||||
forceStop = forceStop,
|
||||
)
|
||||
return withContext(Dispatchers.IO) {
|
||||
adbService.startApp(
|
||||
packageName = packageName,
|
||||
displayId = displayId,
|
||||
forceStop = forceStop,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
internal data class ConnectedDeviceInfo(
|
||||
data class ConnectedDeviceInfo(
|
||||
val model: String,
|
||||
val serial: String,
|
||||
val manufacturer: String,
|
||||
|
||||
@@ -3,7 +3,6 @@ package io.github.miuzarte.scrcpyforandroid.services
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.OpenableColumns
|
||||
import android.webkit.MimeTypeMap
|
||||
@@ -145,10 +144,7 @@ object FileManagerService {
|
||||
): Boolean = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
NativeAdbService.ensureConnectionResponsive()
|
||||
val targetRoot = File(
|
||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
|
||||
"Scrcpy"
|
||||
)
|
||||
val targetRoot = PublicDirs.transferDirectory()
|
||||
ensureDirectoryExists(targetRoot)
|
||||
val targetFile = uniqueFile(targetRoot, fileName)
|
||||
targetFile.outputStream().use { output ->
|
||||
@@ -163,10 +159,7 @@ object FileManagerService {
|
||||
runCatching {
|
||||
NativeAdbService.ensureConnectionResponsive()
|
||||
val rootName = snapshot.remoteRootPath.substringAfterLast('/').ifBlank { "Scrcpy" }
|
||||
val publicRoot = File(
|
||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
|
||||
"Scrcpy"
|
||||
)
|
||||
val publicRoot = PublicDirs.transferDirectory()
|
||||
ensureDirectoryExists(publicRoot)
|
||||
val destinationRoot = uniqueDirectory(publicRoot, rootName)
|
||||
ensureDirectoryExists(destinationRoot)
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
package io.github.miuzarte.scrcpyforandroid.services
|
||||
|
||||
import android.media.MediaCodec
|
||||
import android.media.MediaCodecInfo
|
||||
import android.media.MediaFormat
|
||||
import android.util.Log
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
class NativeAacRecorder(
|
||||
outputFile: File,
|
||||
private val inputCodec: Codec,
|
||||
bitRate: Int,
|
||||
) {
|
||||
private val output = FileOutputStream(outputFile)
|
||||
private val outputBitRate = bitRate.takeIf { it > 0 } ?: DEFAULT_AAC_BIT_RATE
|
||||
private val decoderBufferInfo = MediaCodec.BufferInfo()
|
||||
private val encoderBufferInfo = MediaCodec.BufferInfo()
|
||||
|
||||
private var decoder: MediaCodec? = null
|
||||
private var encoder: MediaCodec? = null
|
||||
private var released = false
|
||||
private var decoderPrepared = inputCodec == Codec.RAW
|
||||
private var encoderPrepared = false
|
||||
private var decoderInputEnded = false
|
||||
private var encoderInputEnded = false
|
||||
private var encoderOutputEnded = false
|
||||
private var reusablePcmBuffer = ByteArray(0)
|
||||
|
||||
fun feedPacket(data: ByteArray, ptsUs: Long, isConfig: Boolean) {
|
||||
if (released) return
|
||||
if (isConfig) {
|
||||
if (inputCodec != Codec.RAW) {
|
||||
prepareDecoder(data)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ensureEncoder()
|
||||
|
||||
if (inputCodec == Codec.RAW) {
|
||||
queuePcmToEncoder(data, ptsUs)
|
||||
drainEncoder()
|
||||
return
|
||||
}
|
||||
|
||||
if (!decoderPrepared) return
|
||||
val currentDecoder = decoder ?: return
|
||||
val inputIndex = currentDecoder.dequeueInputBuffer(CODEC_TIMEOUT_US)
|
||||
if (inputIndex < 0) {
|
||||
drainDecoder()
|
||||
drainEncoder()
|
||||
return
|
||||
}
|
||||
val inputBuffer = currentDecoder.getInputBuffer(inputIndex) ?: return
|
||||
inputBuffer.clear()
|
||||
inputBuffer.put(data)
|
||||
currentDecoder.queueInputBuffer(inputIndex, 0, data.size, ptsUs, 0)
|
||||
drainDecoder()
|
||||
drainEncoder()
|
||||
}
|
||||
|
||||
fun release() {
|
||||
if (released) return
|
||||
released = true
|
||||
runCatching {
|
||||
signalEndOfStream()
|
||||
drainDecoder()
|
||||
drainEncoder()
|
||||
}.onFailure {
|
||||
Log.w(TAG, "release(): final drain failed", it)
|
||||
}
|
||||
runCatching { decoder?.stop() }
|
||||
runCatching { decoder?.release() }
|
||||
runCatching { encoder?.stop() }
|
||||
runCatching { encoder?.release() }
|
||||
runCatching { output.flush() }
|
||||
runCatching { output.close() }
|
||||
decoder = null
|
||||
encoder = null
|
||||
}
|
||||
|
||||
private fun prepareDecoder(config: ByteArray) {
|
||||
if (decoderPrepared || released) return
|
||||
runCatching {
|
||||
val format = when (inputCodec) {
|
||||
Codec.OPUS -> createOpusFormat(config)
|
||||
Codec.AAC -> createAacFormat(config)
|
||||
Codec.FLAC -> createFlacFormat(config)
|
||||
else -> null
|
||||
} ?: return
|
||||
val mime = format.getString(MediaFormat.KEY_MIME) ?: return
|
||||
val codec = MediaCodec.createDecoderByType(mime)
|
||||
codec.configure(format, null, null, 0)
|
||||
codec.start()
|
||||
decoder = codec
|
||||
decoderPrepared = true
|
||||
}.onFailure {
|
||||
Log.w(TAG, "prepareDecoder(): codec=$inputCodec failed", it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureEncoder() {
|
||||
if (encoderPrepared || released) return
|
||||
val format = MediaFormat.createAudioFormat(
|
||||
MediaFormat.MIMETYPE_AUDIO_AAC,
|
||||
SAMPLE_RATE,
|
||||
CHANNEL_COUNT,
|
||||
).apply {
|
||||
setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC)
|
||||
setInteger(MediaFormat.KEY_BIT_RATE, outputBitRate)
|
||||
setInteger(MediaFormat.KEY_PCM_ENCODING, PCM_ENCODING)
|
||||
setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, MAX_ENCODER_INPUT_SIZE)
|
||||
}
|
||||
val codec = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC)
|
||||
codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
||||
codec.start()
|
||||
encoder = codec
|
||||
encoderPrepared = true
|
||||
}
|
||||
|
||||
private fun drainDecoder() {
|
||||
val currentDecoder = decoder ?: return
|
||||
while (true) {
|
||||
when (val index = currentDecoder.dequeueOutputBuffer(decoderBufferInfo, 0L)) {
|
||||
MediaCodec.INFO_TRY_AGAIN_LATER -> return
|
||||
MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> Unit
|
||||
else -> {
|
||||
if (index < 0) continue
|
||||
val outputBuffer = currentDecoder.getOutputBuffer(index)
|
||||
if (outputBuffer != null && decoderBufferInfo.size > 0) {
|
||||
ensurePcmBufferCapacity(decoderBufferInfo.size)
|
||||
outputBuffer.position(decoderBufferInfo.offset)
|
||||
outputBuffer.limit(decoderBufferInfo.offset + decoderBufferInfo.size)
|
||||
outputBuffer.get(reusablePcmBuffer, 0, decoderBufferInfo.size)
|
||||
queuePcmToEncoder(
|
||||
reusablePcmBuffer,
|
||||
decoderBufferInfo.presentationTimeUs,
|
||||
decoderBufferInfo.size,
|
||||
)
|
||||
}
|
||||
val eos = decoderBufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
|
||||
currentDecoder.releaseOutputBuffer(index, false)
|
||||
if (eos) {
|
||||
decoderInputEnded = true
|
||||
signalEncoderEndOfStream()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun queuePcmToEncoder(
|
||||
data: ByteArray,
|
||||
ptsUs: Long,
|
||||
sizeOverride: Int = data.size,
|
||||
) {
|
||||
val currentEncoder = encoder ?: return
|
||||
var remaining = sizeOverride
|
||||
var offset = 0
|
||||
while (remaining > 0) {
|
||||
val inputIndex = currentEncoder.dequeueInputBuffer(CODEC_TIMEOUT_US)
|
||||
if (inputIndex < 0) {
|
||||
drainEncoder()
|
||||
return
|
||||
}
|
||||
val inputBuffer = currentEncoder.getInputBuffer(inputIndex) ?: return
|
||||
val chunkSize = minOf(inputBuffer.capacity(), remaining)
|
||||
inputBuffer.clear()
|
||||
inputBuffer.put(data, offset, chunkSize)
|
||||
val chunkPtsUs = ptsUs + pcmBytesToDurationUs(offset)
|
||||
currentEncoder.queueInputBuffer(inputIndex, 0, chunkSize, chunkPtsUs, 0)
|
||||
offset += chunkSize
|
||||
remaining -= chunkSize
|
||||
}
|
||||
}
|
||||
|
||||
private fun drainEncoder() {
|
||||
val currentEncoder = encoder ?: return
|
||||
while (true) {
|
||||
when (val index = currentEncoder.dequeueOutputBuffer(encoderBufferInfo, 0L)) {
|
||||
MediaCodec.INFO_TRY_AGAIN_LATER -> return
|
||||
MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> Unit
|
||||
else -> {
|
||||
if (index < 0) continue
|
||||
val outputBuffer = currentEncoder.getOutputBuffer(index)
|
||||
val codecConfig =
|
||||
encoderBufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0
|
||||
if (outputBuffer != null &&
|
||||
encoderBufferInfo.size > 0 &&
|
||||
!codecConfig
|
||||
) {
|
||||
outputBuffer.position(encoderBufferInfo.offset)
|
||||
outputBuffer.limit(encoderBufferInfo.offset + encoderBufferInfo.size)
|
||||
val data = ByteArray(encoderBufferInfo.size)
|
||||
outputBuffer.get(data)
|
||||
output.write(adtsHeader(data.size))
|
||||
output.write(data)
|
||||
}
|
||||
val eos = encoderBufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
|
||||
currentEncoder.releaseOutputBuffer(index, false)
|
||||
if (eos) {
|
||||
encoderOutputEnded = true
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun signalEndOfStream() {
|
||||
if (inputCodec != Codec.RAW && !decoderInputEnded) {
|
||||
val currentDecoder = decoder ?: return
|
||||
val inputIndex = currentDecoder.dequeueInputBuffer(CODEC_TIMEOUT_US)
|
||||
if (inputIndex >= 0) {
|
||||
currentDecoder.queueInputBuffer(
|
||||
inputIndex,
|
||||
0,
|
||||
0,
|
||||
0L,
|
||||
MediaCodec.BUFFER_FLAG_END_OF_STREAM,
|
||||
)
|
||||
decoderInputEnded = true
|
||||
}
|
||||
return
|
||||
}
|
||||
signalEncoderEndOfStream()
|
||||
}
|
||||
|
||||
private fun signalEncoderEndOfStream() {
|
||||
if (encoderInputEnded || encoderOutputEnded) return
|
||||
val currentEncoder = encoder ?: return
|
||||
val inputIndex = currentEncoder.dequeueInputBuffer(CODEC_TIMEOUT_US)
|
||||
if (inputIndex >= 0) {
|
||||
currentEncoder.queueInputBuffer(
|
||||
inputIndex,
|
||||
0,
|
||||
0,
|
||||
0L,
|
||||
MediaCodec.BUFFER_FLAG_END_OF_STREAM,
|
||||
)
|
||||
encoderInputEnded = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun adtsHeader(payloadSize: Int): ByteArray {
|
||||
val packetSize = payloadSize + ADTS_HEADER_SIZE
|
||||
val profile = 2
|
||||
val freqIndex = 3
|
||||
val channelConfig = CHANNEL_COUNT
|
||||
return byteArrayOf(
|
||||
0xFF.toByte(),
|
||||
0xF1.toByte(),
|
||||
(((profile - 1) shl 6) or (freqIndex shl 2) or (channelConfig shr 2)).toByte(),
|
||||
((((channelConfig and 3) shl 6) or (packetSize shr 11)).and(0xFF)).toByte(),
|
||||
((packetSize shr 3) and 0xFF).toByte(),
|
||||
((((packetSize and 7) shl 5) or 0x1F).and(0xFF)).toByte(),
|
||||
0xFC.toByte(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun createOpusFormat(opusHead: ByteArray): MediaFormat {
|
||||
return MediaFormat.createAudioFormat(
|
||||
MediaFormat.MIMETYPE_AUDIO_OPUS,
|
||||
SAMPLE_RATE,
|
||||
CHANNEL_COUNT,
|
||||
).apply {
|
||||
setByteBuffer("csd-0", ByteBuffer.wrap(opusHead))
|
||||
if (opusHead.size >= 12) {
|
||||
val preSkip =
|
||||
((opusHead[11].toInt() and 0xFF) shl 8) or (opusHead[10].toInt() and 0xFF)
|
||||
val codecDelayNs = preSkip.toLong() * 1_000_000_000L / SAMPLE_RATE
|
||||
setByteBuffer("csd-1", longBuffer(codecDelayNs))
|
||||
setByteBuffer("csd-2", longBuffer(OPUS_SEEK_PREROLL_NS))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createAacFormat(config: ByteArray): MediaFormat {
|
||||
return MediaFormat.createAudioFormat(
|
||||
MediaFormat.MIMETYPE_AUDIO_AAC,
|
||||
SAMPLE_RATE,
|
||||
CHANNEL_COUNT,
|
||||
).apply {
|
||||
setByteBuffer("csd-0", ByteBuffer.wrap(config))
|
||||
}
|
||||
}
|
||||
|
||||
private fun createFlacFormat(config: ByteArray): MediaFormat {
|
||||
return MediaFormat.createAudioFormat(
|
||||
MediaFormat.MIMETYPE_AUDIO_FLAC,
|
||||
SAMPLE_RATE,
|
||||
CHANNEL_COUNT,
|
||||
).apply {
|
||||
if (config.isNotEmpty()) {
|
||||
setByteBuffer("csd-0", ByteBuffer.wrap(config))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensurePcmBufferCapacity(requiredSize: Int) {
|
||||
if (reusablePcmBuffer.size < requiredSize) {
|
||||
reusablePcmBuffer = ByteArray(requiredSize)
|
||||
}
|
||||
}
|
||||
|
||||
private fun pcmBytesToDurationUs(byteCount: Int): Long {
|
||||
return byteCount.toLong() * 1_000_000L / BYTES_PER_SECOND_PCM_16_STEREO
|
||||
}
|
||||
|
||||
private fun longBuffer(value: Long): ByteBuffer {
|
||||
return ByteBuffer.allocate(8).order(ByteOrder.nativeOrder()).apply {
|
||||
putLong(value)
|
||||
flip()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NativeAacRecorder"
|
||||
private const val SAMPLE_RATE = 48_000
|
||||
private const val CHANNEL_COUNT = 2
|
||||
private const val PCM_ENCODING = 2
|
||||
private const val CODEC_TIMEOUT_US = 10_000L
|
||||
private const val OPUS_SEEK_PREROLL_NS = 80_000_000L
|
||||
private const val DEFAULT_AAC_BIT_RATE = 128_000
|
||||
private const val MAX_ENCODER_INPUT_SIZE = 16 * 1024
|
||||
private const val BYTES_PER_SECOND_PCM_16_STEREO = SAMPLE_RATE * CHANNEL_COUNT * 2
|
||||
private const val ADTS_HEADER_SIZE = 7
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
package io.github.miuzarte.scrcpyforandroid.services
|
||||
|
||||
import android.media.MediaCodec
|
||||
import android.media.MediaCodecInfo
|
||||
import android.media.MediaFormat
|
||||
import android.media.MediaMuxer
|
||||
import android.util.Log
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
|
||||
import java.io.File
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
class NativeAudioRecorder(
|
||||
private val outputFile: File,
|
||||
private val inputCodec: Codec,
|
||||
bitRate: Int,
|
||||
) {
|
||||
private val outputBitRate = bitRate.takeIf { it > 0 } ?: DEFAULT_AAC_BIT_RATE
|
||||
private val decoderBufferInfo = MediaCodec.BufferInfo()
|
||||
private val encoderBufferInfo = MediaCodec.BufferInfo()
|
||||
private val muxer = MediaMuxer(
|
||||
outputFile.absolutePath,
|
||||
MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4,
|
||||
)
|
||||
|
||||
private var decoder: MediaCodec? = null
|
||||
private var encoder: MediaCodec? = null
|
||||
private var trackIndex = -1
|
||||
private var muxerStarted = false
|
||||
private var released = false
|
||||
private var decoderPrepared = inputCodec == Codec.RAW
|
||||
private var encoderPrepared = false
|
||||
private var decoderInputEnded = false
|
||||
private var encoderInputEnded = false
|
||||
private var encoderOutputEnded = false
|
||||
private var reusablePcmBuffer = ByteArray(0)
|
||||
|
||||
fun feedPacket(data: ByteArray, ptsUs: Long, isConfig: Boolean) {
|
||||
if (released) return
|
||||
|
||||
if (isConfig) {
|
||||
if (inputCodec != Codec.RAW) {
|
||||
prepareDecoder(data)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ensureEncoder()
|
||||
|
||||
if (inputCodec == Codec.RAW) {
|
||||
queuePcmToEncoder(data, ptsUs)
|
||||
drainEncoder()
|
||||
return
|
||||
}
|
||||
|
||||
if (!decoderPrepared) return
|
||||
val currentDecoder = decoder ?: return
|
||||
|
||||
val inputIndex = currentDecoder.dequeueInputBuffer(CODEC_TIMEOUT_US)
|
||||
if (inputIndex < 0) {
|
||||
drainDecoder()
|
||||
drainEncoder()
|
||||
return
|
||||
}
|
||||
|
||||
val inputBuffer = currentDecoder.getInputBuffer(inputIndex) ?: return
|
||||
inputBuffer.clear()
|
||||
inputBuffer.put(data)
|
||||
currentDecoder.queueInputBuffer(inputIndex, 0, data.size, ptsUs, 0)
|
||||
drainDecoder()
|
||||
drainEncoder()
|
||||
}
|
||||
|
||||
fun release() {
|
||||
if (released) return
|
||||
released = true
|
||||
|
||||
runCatching {
|
||||
signalEndOfStream()
|
||||
drainDecoder()
|
||||
drainEncoder()
|
||||
}.onFailure {
|
||||
Log.w(TAG, "release(): final drain failed", it)
|
||||
}
|
||||
|
||||
runCatching { decoder?.stop() }
|
||||
runCatching { decoder?.release() }
|
||||
runCatching { encoder?.stop() }
|
||||
runCatching { encoder?.release() }
|
||||
runCatching {
|
||||
if (muxerStarted) {
|
||||
muxer.stop()
|
||||
}
|
||||
}
|
||||
runCatching { muxer.release() }
|
||||
|
||||
decoder = null
|
||||
encoder = null
|
||||
}
|
||||
|
||||
private fun prepareDecoder(config: ByteArray) {
|
||||
if (decoderPrepared || released) return
|
||||
runCatching {
|
||||
val format = when (inputCodec) {
|
||||
Codec.OPUS -> createOpusFormat(config)
|
||||
Codec.AAC -> createAacFormat(config)
|
||||
Codec.FLAC -> createFlacFormat(config)
|
||||
else -> null
|
||||
} ?: return
|
||||
val mime = format.getString(MediaFormat.KEY_MIME) ?: return
|
||||
val codec = MediaCodec.createDecoderByType(mime)
|
||||
codec.configure(format, null, null, 0)
|
||||
codec.start()
|
||||
decoder = codec
|
||||
decoderPrepared = true
|
||||
}.onFailure {
|
||||
Log.w(TAG, "prepareDecoder(): codec=$inputCodec failed", it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureEncoder() {
|
||||
if (encoderPrepared || released) return
|
||||
val format = MediaFormat.createAudioFormat(
|
||||
MediaFormat.MIMETYPE_AUDIO_AAC,
|
||||
SAMPLE_RATE,
|
||||
CHANNEL_COUNT,
|
||||
).apply {
|
||||
setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC)
|
||||
setInteger(MediaFormat.KEY_BIT_RATE, outputBitRate)
|
||||
setInteger(MediaFormat.KEY_PCM_ENCODING, PCM_ENCODING)
|
||||
setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, MAX_ENCODER_INPUT_SIZE)
|
||||
}
|
||||
val codec = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC)
|
||||
codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
||||
codec.start()
|
||||
encoder = codec
|
||||
encoderPrepared = true
|
||||
}
|
||||
|
||||
private fun drainDecoder() {
|
||||
val currentDecoder = decoder ?: return
|
||||
while (true) {
|
||||
when (val index = currentDecoder.dequeueOutputBuffer(decoderBufferInfo, 0L)) {
|
||||
MediaCodec.INFO_TRY_AGAIN_LATER -> return
|
||||
MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> Unit
|
||||
else -> {
|
||||
if (index < 0) continue
|
||||
val outputBuffer = currentDecoder.getOutputBuffer(index)
|
||||
if (outputBuffer != null && decoderBufferInfo.size > 0) {
|
||||
ensurePcmBufferCapacity(decoderBufferInfo.size)
|
||||
outputBuffer.position(decoderBufferInfo.offset)
|
||||
outputBuffer.limit(decoderBufferInfo.offset + decoderBufferInfo.size)
|
||||
outputBuffer.get(reusablePcmBuffer, 0, decoderBufferInfo.size)
|
||||
queuePcmToEncoder(
|
||||
reusablePcmBuffer,
|
||||
decoderBufferInfo.presentationTimeUs,
|
||||
decoderBufferInfo.size,
|
||||
)
|
||||
}
|
||||
val eos = decoderBufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
|
||||
currentDecoder.releaseOutputBuffer(index, false)
|
||||
if (eos) {
|
||||
decoderInputEnded = true
|
||||
signalEncoderEndOfStream()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun queuePcmToEncoder(
|
||||
data: ByteArray,
|
||||
ptsUs: Long,
|
||||
sizeOverride: Int = data.size,
|
||||
) {
|
||||
val currentEncoder = encoder ?: return
|
||||
var remaining = sizeOverride
|
||||
var offset = 0
|
||||
while (remaining > 0) {
|
||||
val inputIndex = currentEncoder.dequeueInputBuffer(CODEC_TIMEOUT_US)
|
||||
if (inputIndex < 0) {
|
||||
drainEncoder()
|
||||
return
|
||||
}
|
||||
val inputBuffer = currentEncoder.getInputBuffer(inputIndex) ?: return
|
||||
val chunkSize = minOf(inputBuffer.capacity(), remaining)
|
||||
inputBuffer.clear()
|
||||
inputBuffer.put(data, offset, chunkSize)
|
||||
val chunkPtsUs = ptsUs + pcmBytesToDurationUs(offset)
|
||||
currentEncoder.queueInputBuffer(inputIndex, 0, chunkSize, chunkPtsUs, 0)
|
||||
offset += chunkSize
|
||||
remaining -= chunkSize
|
||||
}
|
||||
}
|
||||
|
||||
private fun drainEncoder() {
|
||||
val currentEncoder = encoder ?: return
|
||||
while (true) {
|
||||
when (val index = currentEncoder.dequeueOutputBuffer(encoderBufferInfo, 0L)) {
|
||||
MediaCodec.INFO_TRY_AGAIN_LATER -> return
|
||||
MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
|
||||
if (trackIndex >= 0) {
|
||||
throw IllegalStateException("encoder output format changed twice")
|
||||
}
|
||||
trackIndex = muxer.addTrack(currentEncoder.outputFormat)
|
||||
muxer.start()
|
||||
muxerStarted = true
|
||||
}
|
||||
else -> {
|
||||
if (index < 0) continue
|
||||
val outputBuffer = currentEncoder.getOutputBuffer(index)
|
||||
val codecConfig =
|
||||
encoderBufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0
|
||||
if (outputBuffer != null &&
|
||||
encoderBufferInfo.size > 0 &&
|
||||
muxerStarted &&
|
||||
!codecConfig
|
||||
) {
|
||||
outputBuffer.position(encoderBufferInfo.offset)
|
||||
outputBuffer.limit(encoderBufferInfo.offset + encoderBufferInfo.size)
|
||||
muxer.writeSampleData(trackIndex, outputBuffer, encoderBufferInfo)
|
||||
}
|
||||
val eos = encoderBufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
|
||||
currentEncoder.releaseOutputBuffer(index, false)
|
||||
if (eos) {
|
||||
encoderOutputEnded = true
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun signalEndOfStream() {
|
||||
if (inputCodec != Codec.RAW && !decoderInputEnded) {
|
||||
val currentDecoder = decoder ?: return
|
||||
val inputIndex = currentDecoder.dequeueInputBuffer(CODEC_TIMEOUT_US)
|
||||
if (inputIndex >= 0) {
|
||||
currentDecoder.queueInputBuffer(
|
||||
inputIndex,
|
||||
0,
|
||||
0,
|
||||
0L,
|
||||
MediaCodec.BUFFER_FLAG_END_OF_STREAM,
|
||||
)
|
||||
decoderInputEnded = true
|
||||
}
|
||||
return
|
||||
}
|
||||
signalEncoderEndOfStream()
|
||||
}
|
||||
|
||||
private fun signalEncoderEndOfStream() {
|
||||
if (encoderInputEnded || encoderOutputEnded) return
|
||||
val currentEncoder = encoder ?: return
|
||||
val inputIndex = currentEncoder.dequeueInputBuffer(CODEC_TIMEOUT_US)
|
||||
if (inputIndex >= 0) {
|
||||
currentEncoder.queueInputBuffer(
|
||||
inputIndex,
|
||||
0,
|
||||
0,
|
||||
0L,
|
||||
MediaCodec.BUFFER_FLAG_END_OF_STREAM,
|
||||
)
|
||||
encoderInputEnded = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun createOpusFormat(opusHead: ByteArray): MediaFormat {
|
||||
return MediaFormat.createAudioFormat(
|
||||
MediaFormat.MIMETYPE_AUDIO_OPUS,
|
||||
SAMPLE_RATE,
|
||||
CHANNEL_COUNT,
|
||||
).apply {
|
||||
setByteBuffer("csd-0", ByteBuffer.wrap(opusHead))
|
||||
if (opusHead.size >= 12) {
|
||||
val preSkip =
|
||||
((opusHead[11].toInt() and 0xFF) shl 8) or (opusHead[10].toInt() and 0xFF)
|
||||
val codecDelayNs = preSkip.toLong() * 1_000_000_000L / SAMPLE_RATE
|
||||
setByteBuffer("csd-1", longBuffer(codecDelayNs))
|
||||
setByteBuffer("csd-2", longBuffer(OPUS_SEEK_PREROLL_NS))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createAacFormat(config: ByteArray): MediaFormat {
|
||||
return MediaFormat.createAudioFormat(
|
||||
MediaFormat.MIMETYPE_AUDIO_AAC,
|
||||
SAMPLE_RATE,
|
||||
CHANNEL_COUNT,
|
||||
).apply {
|
||||
setByteBuffer("csd-0", ByteBuffer.wrap(config))
|
||||
}
|
||||
}
|
||||
|
||||
private fun createFlacFormat(config: ByteArray): MediaFormat {
|
||||
return MediaFormat.createAudioFormat(
|
||||
MediaFormat.MIMETYPE_AUDIO_FLAC,
|
||||
SAMPLE_RATE,
|
||||
CHANNEL_COUNT,
|
||||
).apply {
|
||||
if (config.isNotEmpty()) {
|
||||
setByteBuffer("csd-0", ByteBuffer.wrap(config))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensurePcmBufferCapacity(requiredSize: Int) {
|
||||
if (reusablePcmBuffer.size < requiredSize) {
|
||||
reusablePcmBuffer = ByteArray(requiredSize)
|
||||
}
|
||||
}
|
||||
|
||||
private fun pcmBytesToDurationUs(byteCount: Int): Long {
|
||||
return byteCount.toLong() * 1_000_000L / BYTES_PER_SECOND_PCM_16_STEREO
|
||||
}
|
||||
|
||||
private fun longBuffer(value: Long): ByteBuffer {
|
||||
return ByteBuffer.allocate(8).order(ByteOrder.nativeOrder()).apply {
|
||||
putLong(value)
|
||||
flip()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NativeAudioRecorder"
|
||||
private const val SAMPLE_RATE = 48_000
|
||||
private const val CHANNEL_COUNT = 2
|
||||
private const val PCM_ENCODING = 2
|
||||
private const val CODEC_TIMEOUT_US = 10_000L
|
||||
private const val OPUS_SEEK_PREROLL_NS = 80_000_000L
|
||||
private const val DEFAULT_AAC_BIT_RATE = 128_000
|
||||
private const val MAX_ENCODER_INPUT_SIZE = 16 * 1024
|
||||
private const val BYTES_PER_SECOND_PCM_16_STEREO = SAMPLE_RATE * CHANNEL_COUNT * 2
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,580 @@
|
||||
package io.github.miuzarte.scrcpyforandroid.services
|
||||
|
||||
import android.media.MediaCodec
|
||||
import android.media.MediaCodecInfo
|
||||
import android.media.MediaFormat
|
||||
import android.media.MediaMuxer
|
||||
import android.util.Log
|
||||
import android.view.Surface
|
||||
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
|
||||
import java.io.File
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
class NativeMp4Recorder(
|
||||
outputFile: File,
|
||||
private val includeVideo: Boolean,
|
||||
private val includeAudio: Boolean,
|
||||
private val inputAudioCodec: Codec?,
|
||||
private val width: Int,
|
||||
private val height: Int,
|
||||
videoBitRate: Int,
|
||||
audioBitRate: Int,
|
||||
) {
|
||||
private enum class TrackType { AUDIO, VIDEO }
|
||||
|
||||
private data class PendingSample(
|
||||
val trackType: TrackType,
|
||||
val data: ByteArray,
|
||||
val presentationTimeUs: Long,
|
||||
val flags: Int,
|
||||
)
|
||||
|
||||
private val lock = Any()
|
||||
private val muxer = MediaMuxer(
|
||||
outputFile.absolutePath,
|
||||
MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4,
|
||||
)
|
||||
private val audioBitRate = audioBitRate.takeIf { it > 0 } ?: DEFAULT_AAC_BIT_RATE
|
||||
private val resolvedVideoBitRate = videoBitRate.takeIf { it > 0 }
|
||||
?: (width * height * DEFAULT_VIDEO_BITS_PER_PIXEL).coerceAtLeast(MIN_VIDEO_BIT_RATE)
|
||||
|
||||
private val audioDecoderBufferInfo = MediaCodec.BufferInfo()
|
||||
private val audioEncoderBufferInfo = MediaCodec.BufferInfo()
|
||||
private val videoEncoderBufferInfo = MediaCodec.BufferInfo()
|
||||
|
||||
private var audioDecoder: MediaCodec? = null
|
||||
private var audioEncoder: MediaCodec? = null
|
||||
private var videoEncoder: MediaCodec? = null
|
||||
private var videoInputSurface: Surface? = null
|
||||
|
||||
private var audioTrackIndex = -1
|
||||
private var videoTrackIndex = -1
|
||||
private var muxerStarted = false
|
||||
private var released = false
|
||||
private var audioBasePtsUs: Long? = null
|
||||
private var videoBasePtsUs: Long? = null
|
||||
private var lastAudioPtsUs = 0L
|
||||
private var lastVideoPtsUs = 0L
|
||||
|
||||
private var audioDecoderPrepared = !includeAudio || inputAudioCodec == Codec.RAW
|
||||
private var audioEncoderPrepared = false
|
||||
private var audioDecoderInputEnded = false
|
||||
private var audioEncoderInputEnded = false
|
||||
private var audioEncoderOutputEnded = false
|
||||
private var videoEncoderInputEnded = false
|
||||
private var videoEncoderOutputEnded = false
|
||||
|
||||
private var reusablePcmBuffer = ByteArray(0)
|
||||
private val pendingSamples = mutableListOf<PendingSample>()
|
||||
|
||||
suspend fun start() {
|
||||
if (!includeVideo) return
|
||||
setupVideoEncoder()
|
||||
val surface = synchronized(lock) { videoInputSurface } ?: return
|
||||
NativeCoreFacade.attachRecordingSurface(
|
||||
surface = surface,
|
||||
width = width,
|
||||
height = height,
|
||||
onFrameRendered = { onVideoFrameRendered() },
|
||||
)
|
||||
}
|
||||
|
||||
fun feedAudioPacket(data: ByteArray, ptsUs: Long, isConfig: Boolean) {
|
||||
if (!includeAudio) return
|
||||
synchronized(lock) {
|
||||
if (released) return
|
||||
|
||||
if (isConfig) {
|
||||
if (inputAudioCodec != null && inputAudioCodec != Codec.RAW) {
|
||||
prepareAudioDecoder(data)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ensureAudioEncoder()
|
||||
|
||||
if (inputAudioCodec == Codec.RAW) {
|
||||
queuePcmToAudioEncoder(data, ptsUs)
|
||||
drainAudioEncoderLocked()
|
||||
return
|
||||
}
|
||||
|
||||
if (!audioDecoderPrepared) return
|
||||
val currentDecoder = audioDecoder ?: return
|
||||
val inputIndex = currentDecoder.dequeueInputBuffer(CODEC_TIMEOUT_US)
|
||||
if (inputIndex < 0) {
|
||||
drainAudioDecoderLocked()
|
||||
drainAudioEncoderLocked()
|
||||
return
|
||||
}
|
||||
val inputBuffer = currentDecoder.getInputBuffer(inputIndex) ?: return
|
||||
inputBuffer.clear()
|
||||
inputBuffer.put(data)
|
||||
currentDecoder.queueInputBuffer(inputIndex, 0, data.size, ptsUs, 0)
|
||||
drainAudioDecoderLocked()
|
||||
drainAudioEncoderLocked()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun release() {
|
||||
val surface = synchronized(lock) {
|
||||
if (released) return
|
||||
released = true
|
||||
videoInputSurface
|
||||
}
|
||||
|
||||
if (includeVideo && surface != null) {
|
||||
NativeCoreFacade.detachRecordingSurface(surface, releaseSurface = false)
|
||||
}
|
||||
|
||||
synchronized(lock) {
|
||||
runCatching {
|
||||
signalAudioEndOfStreamLocked()
|
||||
signalVideoEndOfStreamLocked()
|
||||
drainAudioDecoderLocked()
|
||||
drainAudioEncoderLocked()
|
||||
drainVideoEncoderLocked()
|
||||
}.onFailure {
|
||||
Log.w(TAG, "release(): final drain failed", it)
|
||||
}
|
||||
|
||||
runCatching { audioDecoder?.stop() }
|
||||
runCatching { audioDecoder?.release() }
|
||||
runCatching { audioEncoder?.stop() }
|
||||
runCatching { audioEncoder?.release() }
|
||||
runCatching { videoEncoder?.stop() }
|
||||
runCatching { videoEncoder?.release() }
|
||||
runCatching { surface?.release() }
|
||||
runCatching {
|
||||
if (muxerStarted) {
|
||||
muxer.stop()
|
||||
}
|
||||
}
|
||||
runCatching { muxer.release() }
|
||||
|
||||
audioDecoder = null
|
||||
audioEncoder = null
|
||||
videoEncoder = null
|
||||
videoInputSurface = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun onVideoFrameRendered() {
|
||||
synchronized(lock) {
|
||||
if (released) return
|
||||
drainVideoEncoderLocked()
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupVideoEncoder() {
|
||||
synchronized(lock) {
|
||||
if (!includeVideo || videoEncoder != null || released) return
|
||||
val format = MediaFormat.createVideoFormat(VIDEO_MIME, width, height).apply {
|
||||
setInteger(
|
||||
MediaFormat.KEY_COLOR_FORMAT,
|
||||
MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface,
|
||||
)
|
||||
setInteger(MediaFormat.KEY_BIT_RATE, resolvedVideoBitRate)
|
||||
setInteger(MediaFormat.KEY_FRAME_RATE, DEFAULT_FRAME_RATE)
|
||||
setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, DEFAULT_I_FRAME_INTERVAL)
|
||||
}
|
||||
val codec = MediaCodec.createEncoderByType(VIDEO_MIME)
|
||||
codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
||||
val surface = codec.createInputSurface()
|
||||
codec.start()
|
||||
videoEncoder = codec
|
||||
videoInputSurface = surface
|
||||
}
|
||||
}
|
||||
|
||||
private fun prepareAudioDecoder(config: ByteArray) {
|
||||
if (audioDecoderPrepared || released) return
|
||||
runCatching {
|
||||
val format = when (inputAudioCodec) {
|
||||
Codec.OPUS -> createOpusFormat(config)
|
||||
Codec.AAC -> createAacFormat(config)
|
||||
Codec.FLAC -> createFlacFormat(config)
|
||||
else -> null
|
||||
} ?: return
|
||||
val mime = format.getString(MediaFormat.KEY_MIME) ?: return
|
||||
val codec = MediaCodec.createDecoderByType(mime)
|
||||
codec.configure(format, null, null, 0)
|
||||
codec.start()
|
||||
audioDecoder = codec
|
||||
audioDecoderPrepared = true
|
||||
}.onFailure {
|
||||
Log.w(TAG, "prepareAudioDecoder(): codec=$inputAudioCodec failed", it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureAudioEncoder() {
|
||||
if (!includeAudio || audioEncoderPrepared || released) return
|
||||
val format = MediaFormat.createAudioFormat(
|
||||
MediaFormat.MIMETYPE_AUDIO_AAC,
|
||||
SAMPLE_RATE,
|
||||
CHANNEL_COUNT,
|
||||
).apply {
|
||||
setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC)
|
||||
setInteger(MediaFormat.KEY_BIT_RATE, audioBitRate)
|
||||
setInteger(MediaFormat.KEY_PCM_ENCODING, PCM_ENCODING)
|
||||
setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, MAX_AUDIO_ENCODER_INPUT_SIZE)
|
||||
}
|
||||
val codec = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC)
|
||||
codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
||||
codec.start()
|
||||
audioEncoder = codec
|
||||
audioEncoderPrepared = true
|
||||
}
|
||||
|
||||
private fun drainAudioDecoderLocked() {
|
||||
val currentDecoder = audioDecoder ?: return
|
||||
while (true) {
|
||||
when (val index = currentDecoder.dequeueOutputBuffer(audioDecoderBufferInfo, 0L)) {
|
||||
MediaCodec.INFO_TRY_AGAIN_LATER -> return
|
||||
MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> Unit
|
||||
else -> {
|
||||
if (index < 0) continue
|
||||
val outputBuffer = currentDecoder.getOutputBuffer(index)
|
||||
if (outputBuffer != null && audioDecoderBufferInfo.size > 0) {
|
||||
ensurePcmBufferCapacity(audioDecoderBufferInfo.size)
|
||||
outputBuffer.position(audioDecoderBufferInfo.offset)
|
||||
outputBuffer.limit(audioDecoderBufferInfo.offset + audioDecoderBufferInfo.size)
|
||||
outputBuffer.get(reusablePcmBuffer, 0, audioDecoderBufferInfo.size)
|
||||
queuePcmToAudioEncoder(
|
||||
reusablePcmBuffer,
|
||||
audioDecoderBufferInfo.presentationTimeUs,
|
||||
audioDecoderBufferInfo.size,
|
||||
)
|
||||
}
|
||||
val eos =
|
||||
audioDecoderBufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
|
||||
currentDecoder.releaseOutputBuffer(index, false)
|
||||
if (eos) {
|
||||
audioDecoderInputEnded = true
|
||||
signalAudioEncoderEndOfStreamLocked()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun queuePcmToAudioEncoder(
|
||||
data: ByteArray,
|
||||
ptsUs: Long,
|
||||
sizeOverride: Int = data.size,
|
||||
) {
|
||||
val currentEncoder = audioEncoder ?: return
|
||||
var remaining = sizeOverride
|
||||
var offset = 0
|
||||
while (remaining > 0) {
|
||||
val inputIndex = currentEncoder.dequeueInputBuffer(CODEC_TIMEOUT_US)
|
||||
if (inputIndex < 0) {
|
||||
drainAudioEncoderLocked()
|
||||
return
|
||||
}
|
||||
val inputBuffer = currentEncoder.getInputBuffer(inputIndex) ?: return
|
||||
val chunkSize = minOf(inputBuffer.capacity(), remaining)
|
||||
inputBuffer.clear()
|
||||
inputBuffer.put(data, offset, chunkSize)
|
||||
val chunkPtsUs = ptsUs + pcmBytesToDurationUs(offset)
|
||||
currentEncoder.queueInputBuffer(inputIndex, 0, chunkSize, chunkPtsUs, 0)
|
||||
offset += chunkSize
|
||||
remaining -= chunkSize
|
||||
}
|
||||
}
|
||||
|
||||
private fun drainAudioEncoderLocked() {
|
||||
val currentEncoder = audioEncoder ?: return
|
||||
while (true) {
|
||||
when (val index = currentEncoder.dequeueOutputBuffer(audioEncoderBufferInfo, 0L)) {
|
||||
MediaCodec.INFO_TRY_AGAIN_LATER -> return
|
||||
MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
|
||||
registerTrackLocked(TrackType.AUDIO, currentEncoder.outputFormat)
|
||||
}
|
||||
else -> {
|
||||
if (index < 0) continue
|
||||
val outputBuffer = currentEncoder.getOutputBuffer(index)
|
||||
val codecConfig =
|
||||
audioEncoderBufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0
|
||||
if (outputBuffer != null &&
|
||||
audioEncoderBufferInfo.size > 0 &&
|
||||
!codecConfig
|
||||
) {
|
||||
writeSampleLocked(
|
||||
TrackType.AUDIO,
|
||||
outputBuffer,
|
||||
audioEncoderBufferInfo,
|
||||
)
|
||||
}
|
||||
val eos =
|
||||
audioEncoderBufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
|
||||
currentEncoder.releaseOutputBuffer(index, false)
|
||||
if (eos) {
|
||||
audioEncoderOutputEnded = true
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun drainVideoEncoderLocked() {
|
||||
val currentEncoder = videoEncoder ?: return
|
||||
while (true) {
|
||||
when (val index = currentEncoder.dequeueOutputBuffer(videoEncoderBufferInfo, 0L)) {
|
||||
MediaCodec.INFO_TRY_AGAIN_LATER -> return
|
||||
MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
|
||||
registerTrackLocked(TrackType.VIDEO, currentEncoder.outputFormat)
|
||||
}
|
||||
else -> {
|
||||
if (index < 0) continue
|
||||
val outputBuffer = currentEncoder.getOutputBuffer(index)
|
||||
val codecConfig =
|
||||
videoEncoderBufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0
|
||||
if (outputBuffer != null &&
|
||||
videoEncoderBufferInfo.size > 0 &&
|
||||
!codecConfig
|
||||
) {
|
||||
writeSampleLocked(
|
||||
TrackType.VIDEO,
|
||||
outputBuffer,
|
||||
videoEncoderBufferInfo,
|
||||
)
|
||||
}
|
||||
val eos =
|
||||
videoEncoderBufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
|
||||
currentEncoder.releaseOutputBuffer(index, false)
|
||||
if (eos) {
|
||||
videoEncoderOutputEnded = true
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerTrackLocked(trackType: TrackType, format: MediaFormat) {
|
||||
when (trackType) {
|
||||
TrackType.AUDIO -> {
|
||||
if (audioTrackIndex >= 0) return
|
||||
audioTrackIndex = muxer.addTrack(format)
|
||||
}
|
||||
TrackType.VIDEO -> {
|
||||
if (videoTrackIndex >= 0) return
|
||||
videoTrackIndex = muxer.addTrack(format)
|
||||
}
|
||||
}
|
||||
startMuxerIfReadyLocked()
|
||||
}
|
||||
|
||||
private fun startMuxerIfReadyLocked() {
|
||||
if (muxerStarted) return
|
||||
val audioReady = !includeAudio || audioTrackIndex >= 0
|
||||
val videoReady = !includeVideo || videoTrackIndex >= 0
|
||||
if (!audioReady || !videoReady) return
|
||||
muxer.start()
|
||||
muxerStarted = true
|
||||
if (pendingSamples.isNotEmpty()) {
|
||||
val snapshot = pendingSamples.sortedBy { it.presentationTimeUs }
|
||||
pendingSamples.clear()
|
||||
snapshot.forEach { sample ->
|
||||
val trackIndex = when (sample.trackType) {
|
||||
TrackType.AUDIO -> audioTrackIndex
|
||||
TrackType.VIDEO -> videoTrackIndex
|
||||
}
|
||||
val normalizedPtsUs = normalizePtsLocked(
|
||||
trackType = sample.trackType,
|
||||
presentationTimeUs = sample.presentationTimeUs,
|
||||
)
|
||||
val bufferInfo = MediaCodec.BufferInfo().apply {
|
||||
offset = 0
|
||||
size = sample.data.size
|
||||
presentationTimeUs = normalizedPtsUs
|
||||
flags = sample.flags
|
||||
}
|
||||
muxer.writeSampleData(trackIndex, ByteBuffer.wrap(sample.data), bufferInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeSampleLocked(
|
||||
trackType: TrackType,
|
||||
buffer: ByteBuffer,
|
||||
bufferInfo: MediaCodec.BufferInfo,
|
||||
) {
|
||||
val size = bufferInfo.size
|
||||
val data = ByteArray(size)
|
||||
buffer.position(bufferInfo.offset)
|
||||
buffer.limit(bufferInfo.offset + size)
|
||||
buffer.get(data)
|
||||
|
||||
if (!muxerStarted) {
|
||||
pendingSamples += PendingSample(
|
||||
trackType = trackType,
|
||||
data = data,
|
||||
presentationTimeUs = bufferInfo.presentationTimeUs,
|
||||
flags = bufferInfo.flags,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val trackIndex = when (trackType) {
|
||||
TrackType.AUDIO -> audioTrackIndex
|
||||
TrackType.VIDEO -> videoTrackIndex
|
||||
}
|
||||
val normalizedPtsUs = normalizePtsLocked(
|
||||
trackType = trackType,
|
||||
presentationTimeUs = bufferInfo.presentationTimeUs,
|
||||
)
|
||||
val writeInfo = MediaCodec.BufferInfo().apply {
|
||||
offset = 0
|
||||
this.size = size
|
||||
presentationTimeUs = normalizedPtsUs
|
||||
flags = bufferInfo.flags
|
||||
}
|
||||
muxer.writeSampleData(trackIndex, ByteBuffer.wrap(data), writeInfo)
|
||||
}
|
||||
|
||||
private fun normalizePtsLocked(
|
||||
trackType: TrackType,
|
||||
presentationTimeUs: Long,
|
||||
): Long {
|
||||
return when (trackType) {
|
||||
TrackType.AUDIO -> {
|
||||
val base = audioBasePtsUs ?: presentationTimeUs.also {
|
||||
audioBasePtsUs = it
|
||||
lastAudioPtsUs = 0L
|
||||
}
|
||||
val normalized = (presentationTimeUs - base).coerceAtLeast(0L)
|
||||
normalized.coerceAtLeast(lastAudioPtsUs).also { lastAudioPtsUs = it }
|
||||
}
|
||||
TrackType.VIDEO -> {
|
||||
val base = videoBasePtsUs ?: presentationTimeUs.also {
|
||||
videoBasePtsUs = it
|
||||
lastVideoPtsUs = 0L
|
||||
}
|
||||
val normalized = (presentationTimeUs - base).coerceAtLeast(0L)
|
||||
normalized.coerceAtLeast(lastVideoPtsUs).also { lastVideoPtsUs = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun signalAudioEndOfStreamLocked() {
|
||||
if (!includeAudio) return
|
||||
if (inputAudioCodec != Codec.RAW && !audioDecoderInputEnded) {
|
||||
val currentDecoder = audioDecoder ?: return
|
||||
val inputIndex = currentDecoder.dequeueInputBuffer(CODEC_TIMEOUT_US)
|
||||
if (inputIndex >= 0) {
|
||||
currentDecoder.queueInputBuffer(
|
||||
inputIndex,
|
||||
0,
|
||||
0,
|
||||
0L,
|
||||
MediaCodec.BUFFER_FLAG_END_OF_STREAM,
|
||||
)
|
||||
audioDecoderInputEnded = true
|
||||
}
|
||||
return
|
||||
}
|
||||
signalAudioEncoderEndOfStreamLocked()
|
||||
}
|
||||
|
||||
private fun signalAudioEncoderEndOfStreamLocked() {
|
||||
if (audioEncoderInputEnded || audioEncoderOutputEnded) return
|
||||
val currentEncoder = audioEncoder ?: return
|
||||
val inputIndex = currentEncoder.dequeueInputBuffer(CODEC_TIMEOUT_US)
|
||||
if (inputIndex >= 0) {
|
||||
currentEncoder.queueInputBuffer(
|
||||
inputIndex,
|
||||
0,
|
||||
0,
|
||||
0L,
|
||||
MediaCodec.BUFFER_FLAG_END_OF_STREAM,
|
||||
)
|
||||
audioEncoderInputEnded = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun signalVideoEndOfStreamLocked() {
|
||||
if (!includeVideo || videoEncoderInputEnded || videoEncoderOutputEnded) return
|
||||
val currentEncoder = videoEncoder ?: return
|
||||
currentEncoder.signalEndOfInputStream()
|
||||
videoEncoderInputEnded = true
|
||||
}
|
||||
|
||||
private fun createOpusFormat(opusHead: ByteArray): MediaFormat {
|
||||
return MediaFormat.createAudioFormat(
|
||||
MediaFormat.MIMETYPE_AUDIO_OPUS,
|
||||
SAMPLE_RATE,
|
||||
CHANNEL_COUNT,
|
||||
).apply {
|
||||
setByteBuffer("csd-0", ByteBuffer.wrap(opusHead))
|
||||
if (opusHead.size >= 12) {
|
||||
val preSkip =
|
||||
((opusHead[11].toInt() and 0xFF) shl 8) or (opusHead[10].toInt() and 0xFF)
|
||||
val codecDelayNs = preSkip.toLong() * 1_000_000_000L / SAMPLE_RATE
|
||||
setByteBuffer("csd-1", longBuffer(codecDelayNs))
|
||||
setByteBuffer("csd-2", longBuffer(OPUS_SEEK_PREROLL_NS))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createAacFormat(config: ByteArray): MediaFormat {
|
||||
return MediaFormat.createAudioFormat(
|
||||
MediaFormat.MIMETYPE_AUDIO_AAC,
|
||||
SAMPLE_RATE,
|
||||
CHANNEL_COUNT,
|
||||
).apply {
|
||||
setByteBuffer("csd-0", ByteBuffer.wrap(config))
|
||||
}
|
||||
}
|
||||
|
||||
private fun createFlacFormat(config: ByteArray): MediaFormat {
|
||||
return MediaFormat.createAudioFormat(
|
||||
MediaFormat.MIMETYPE_AUDIO_FLAC,
|
||||
SAMPLE_RATE,
|
||||
CHANNEL_COUNT,
|
||||
).apply {
|
||||
if (config.isNotEmpty()) {
|
||||
setByteBuffer("csd-0", ByteBuffer.wrap(config))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensurePcmBufferCapacity(requiredSize: Int) {
|
||||
if (reusablePcmBuffer.size < requiredSize) {
|
||||
reusablePcmBuffer = ByteArray(requiredSize)
|
||||
}
|
||||
}
|
||||
|
||||
private fun pcmBytesToDurationUs(byteCount: Int): Long {
|
||||
return byteCount.toLong() * 1_000_000L / BYTES_PER_SECOND_PCM_16_STEREO
|
||||
}
|
||||
|
||||
private fun longBuffer(value: Long): ByteBuffer {
|
||||
return ByteBuffer.allocate(8).order(ByteOrder.nativeOrder()).apply {
|
||||
putLong(value)
|
||||
flip()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NativeMp4Recorder"
|
||||
private const val VIDEO_MIME = MediaFormat.MIMETYPE_VIDEO_AVC
|
||||
private const val SAMPLE_RATE = 48_000
|
||||
private const val CHANNEL_COUNT = 2
|
||||
private const val PCM_ENCODING = 2
|
||||
private const val CODEC_TIMEOUT_US = 10_000L
|
||||
private const val OPUS_SEEK_PREROLL_NS = 80_000_000L
|
||||
private const val DEFAULT_AAC_BIT_RATE = 128_000
|
||||
private const val MAX_AUDIO_ENCODER_INPUT_SIZE = 16 * 1024
|
||||
private const val BYTES_PER_SECOND_PCM_16_STEREO = SAMPLE_RATE * CHANNEL_COUNT * 2
|
||||
private const val DEFAULT_FRAME_RATE = 60
|
||||
private const val DEFAULT_I_FRAME_INTERVAL = 2
|
||||
private const val DEFAULT_VIDEO_BITS_PER_PIXEL = 6
|
||||
private const val MIN_VIDEO_BIT_RATE = 4_000_000
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package io.github.miuzarte.scrcpyforandroid.services
|
||||
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions.RecordFormat
|
||||
|
||||
object NativeRecordingSupport {
|
||||
val supportedFormats: List<RecordFormat> = listOf(
|
||||
RecordFormat.AUTO,
|
||||
RecordFormat.MP4,
|
||||
RecordFormat.M4A,
|
||||
RecordFormat.AAC,
|
||||
RecordFormat.WAV,
|
||||
)
|
||||
|
||||
fun isSupported(format: RecordFormat): Boolean {
|
||||
return format in supportedFormats
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package io.github.miuzarte.scrcpyforandroid.services
|
||||
|
||||
import android.media.MediaCodec
|
||||
import android.media.MediaFormat
|
||||
import android.util.Log
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
class NativeWavRecorder(
|
||||
outputFile: File,
|
||||
private val inputCodec: Codec,
|
||||
) {
|
||||
private val output = RandomAccessFile(outputFile, "rw")
|
||||
private val decoderBufferInfo = MediaCodec.BufferInfo()
|
||||
private var decoder: MediaCodec? = null
|
||||
private var released = false
|
||||
private var decoderPrepared = inputCodec == Codec.RAW
|
||||
private var decoderInputEnded = false
|
||||
private var bytesWritten = 0L
|
||||
private var reusablePcmBuffer = ByteArray(0)
|
||||
|
||||
init {
|
||||
output.setLength(0)
|
||||
output.write(ByteArray(WAV_HEADER_SIZE))
|
||||
}
|
||||
|
||||
fun feedPacket(data: ByteArray, ptsUs: Long, isConfig: Boolean) {
|
||||
if (released) return
|
||||
if (isConfig) {
|
||||
if (inputCodec != Codec.RAW) {
|
||||
prepareDecoder(data)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (inputCodec == Codec.RAW) {
|
||||
writePcm(data, data.size)
|
||||
return
|
||||
}
|
||||
|
||||
if (!decoderPrepared) return
|
||||
val currentDecoder = decoder ?: return
|
||||
val inputIndex = currentDecoder.dequeueInputBuffer(CODEC_TIMEOUT_US)
|
||||
if (inputIndex < 0) {
|
||||
drainDecoder()
|
||||
return
|
||||
}
|
||||
val inputBuffer = currentDecoder.getInputBuffer(inputIndex) ?: return
|
||||
inputBuffer.clear()
|
||||
inputBuffer.put(data)
|
||||
currentDecoder.queueInputBuffer(inputIndex, 0, data.size, ptsUs, 0)
|
||||
drainDecoder()
|
||||
}
|
||||
|
||||
fun release() {
|
||||
if (released) return
|
||||
released = true
|
||||
runCatching {
|
||||
signalEndOfStream()
|
||||
drainDecoder()
|
||||
}.onFailure {
|
||||
Log.w(TAG, "release(): final drain failed", it)
|
||||
}
|
||||
runCatching { decoder?.stop() }
|
||||
runCatching { decoder?.release() }
|
||||
runCatching {
|
||||
output.seek(0)
|
||||
output.write(wavHeader(bytesWritten))
|
||||
}
|
||||
runCatching { output.close() }
|
||||
decoder = null
|
||||
}
|
||||
|
||||
private fun prepareDecoder(config: ByteArray) {
|
||||
if (decoderPrepared || released) return
|
||||
runCatching {
|
||||
val format = when (inputCodec) {
|
||||
Codec.OPUS -> createOpusFormat(config)
|
||||
Codec.AAC -> createAacFormat(config)
|
||||
Codec.FLAC -> createFlacFormat(config)
|
||||
else -> null
|
||||
} ?: return
|
||||
val mime = format.getString(MediaFormat.KEY_MIME) ?: return
|
||||
val codec = MediaCodec.createDecoderByType(mime)
|
||||
codec.configure(format, null, null, 0)
|
||||
codec.start()
|
||||
decoder = codec
|
||||
decoderPrepared = true
|
||||
}.onFailure {
|
||||
Log.w(TAG, "prepareDecoder(): codec=$inputCodec failed", it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun drainDecoder() {
|
||||
val currentDecoder = decoder ?: return
|
||||
while (true) {
|
||||
when (val index = currentDecoder.dequeueOutputBuffer(decoderBufferInfo, 0L)) {
|
||||
MediaCodec.INFO_TRY_AGAIN_LATER -> return
|
||||
MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> Unit
|
||||
else -> {
|
||||
if (index < 0) continue
|
||||
val outputBuffer = currentDecoder.getOutputBuffer(index)
|
||||
if (outputBuffer != null && decoderBufferInfo.size > 0) {
|
||||
ensurePcmBufferCapacity(decoderBufferInfo.size)
|
||||
outputBuffer.position(decoderBufferInfo.offset)
|
||||
outputBuffer.limit(decoderBufferInfo.offset + decoderBufferInfo.size)
|
||||
outputBuffer.get(reusablePcmBuffer, 0, decoderBufferInfo.size)
|
||||
writePcm(reusablePcmBuffer, decoderBufferInfo.size)
|
||||
}
|
||||
val eos = decoderBufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
|
||||
currentDecoder.releaseOutputBuffer(index, false)
|
||||
if (eos) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun signalEndOfStream() {
|
||||
if (inputCodec == Codec.RAW || decoderInputEnded) return
|
||||
val currentDecoder = decoder ?: return
|
||||
val inputIndex = currentDecoder.dequeueInputBuffer(CODEC_TIMEOUT_US)
|
||||
if (inputIndex >= 0) {
|
||||
currentDecoder.queueInputBuffer(
|
||||
inputIndex,
|
||||
0,
|
||||
0,
|
||||
0L,
|
||||
MediaCodec.BUFFER_FLAG_END_OF_STREAM,
|
||||
)
|
||||
decoderInputEnded = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun writePcm(buffer: ByteArray, size: Int) {
|
||||
output.write(buffer, 0, size)
|
||||
bytesWritten += size
|
||||
}
|
||||
|
||||
private fun createOpusFormat(opusHead: ByteArray): MediaFormat {
|
||||
return MediaFormat.createAudioFormat(
|
||||
MediaFormat.MIMETYPE_AUDIO_OPUS,
|
||||
SAMPLE_RATE,
|
||||
CHANNEL_COUNT,
|
||||
).apply {
|
||||
setByteBuffer("csd-0", ByteBuffer.wrap(opusHead))
|
||||
if (opusHead.size >= 12) {
|
||||
val preSkip =
|
||||
((opusHead[11].toInt() and 0xFF) shl 8) or (opusHead[10].toInt() and 0xFF)
|
||||
val codecDelayNs = preSkip.toLong() * 1_000_000_000L / SAMPLE_RATE
|
||||
setByteBuffer("csd-1", longBuffer(codecDelayNs))
|
||||
setByteBuffer("csd-2", longBuffer(OPUS_SEEK_PREROLL_NS))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createAacFormat(config: ByteArray): MediaFormat {
|
||||
return MediaFormat.createAudioFormat(
|
||||
MediaFormat.MIMETYPE_AUDIO_AAC,
|
||||
SAMPLE_RATE,
|
||||
CHANNEL_COUNT,
|
||||
).apply {
|
||||
setByteBuffer("csd-0", ByteBuffer.wrap(config))
|
||||
}
|
||||
}
|
||||
|
||||
private fun createFlacFormat(config: ByteArray): MediaFormat {
|
||||
return MediaFormat.createAudioFormat(
|
||||
MediaFormat.MIMETYPE_AUDIO_FLAC,
|
||||
SAMPLE_RATE,
|
||||
CHANNEL_COUNT,
|
||||
).apply {
|
||||
if (config.isNotEmpty()) {
|
||||
setByteBuffer("csd-0", ByteBuffer.wrap(config))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensurePcmBufferCapacity(requiredSize: Int) {
|
||||
if (reusablePcmBuffer.size < requiredSize) {
|
||||
reusablePcmBuffer = ByteArray(requiredSize)
|
||||
}
|
||||
}
|
||||
|
||||
private fun wavHeader(dataSize: Long): ByteArray {
|
||||
val header = ByteBuffer.allocate(WAV_HEADER_SIZE).order(ByteOrder.LITTLE_ENDIAN)
|
||||
val riffSize = (dataSize + 36L).coerceAtMost(UInt.MAX_VALUE.toLong()).toInt()
|
||||
val pcmSize = dataSize.coerceAtMost(UInt.MAX_VALUE.toLong()).toInt()
|
||||
header.put("RIFF".toByteArray(Charsets.US_ASCII))
|
||||
header.putInt(riffSize)
|
||||
header.put("WAVE".toByteArray(Charsets.US_ASCII))
|
||||
header.put("fmt ".toByteArray(Charsets.US_ASCII))
|
||||
header.putInt(16)
|
||||
header.putShort(1)
|
||||
header.putShort(CHANNEL_COUNT.toShort())
|
||||
header.putInt(SAMPLE_RATE)
|
||||
header.putInt(BYTES_PER_SECOND_PCM_16_STEREO)
|
||||
header.putShort((CHANNEL_COUNT * 2).toShort())
|
||||
header.putShort(16)
|
||||
header.put("data".toByteArray(Charsets.US_ASCII))
|
||||
header.putInt(pcmSize)
|
||||
return header.array()
|
||||
}
|
||||
|
||||
private fun longBuffer(value: Long): ByteBuffer {
|
||||
return ByteBuffer.allocate(8).order(ByteOrder.nativeOrder()).apply {
|
||||
putLong(value)
|
||||
flip()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NativeWavRecorder"
|
||||
private const val SAMPLE_RATE = 48_000
|
||||
private const val CHANNEL_COUNT = 2
|
||||
private const val CODEC_TIMEOUT_US = 10_000L
|
||||
private const val OPUS_SEEK_PREROLL_NS = 80_000_000L
|
||||
private const val BYTES_PER_SECOND_PCM_16_STEREO = SAMPLE_RATE * CHANNEL_COUNT * 2
|
||||
private const val WAV_HEADER_SIZE = 44
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package io.github.miuzarte.scrcpyforandroid.services
|
||||
|
||||
import android.os.Environment
|
||||
import java.io.File
|
||||
|
||||
object PublicDirs {
|
||||
private const val ROOT_DIRECTORY = "Scrcpy"
|
||||
|
||||
fun transferDirectory(): File {
|
||||
return File(
|
||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
|
||||
ROOT_DIRECTORY
|
||||
)
|
||||
}
|
||||
|
||||
fun recordDirectory(): File {
|
||||
return File(
|
||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES),
|
||||
ROOT_DIRECTORY
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package io.github.miuzarte.scrcpyforandroid.services
|
||||
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
||||
import java.time.LocalDateTime
|
||||
|
||||
object RecordFilenameTemplate {
|
||||
data class Entry(
|
||||
val value: String,
|
||||
val description: String?,
|
||||
val isTemplate: Boolean,
|
||||
)
|
||||
|
||||
fun resolve(
|
||||
template: String,
|
||||
sessionInfo: Scrcpy.Session.SessionInfo,
|
||||
now: LocalDateTime = LocalDateTime.now(),
|
||||
) =
|
||||
if (template.isBlank()) ""
|
||||
else buildString(template.length) {
|
||||
var index = 0
|
||||
while (index < template.length) {
|
||||
val start = template.indexOf($$"${", index)
|
||||
if (start < 0) {
|
||||
append(template.substring(index))
|
||||
break
|
||||
}
|
||||
append(template.substring(index, start))
|
||||
val end = template.indexOf('}', start + 2)
|
||||
if (end < 0) {
|
||||
append(template.substring(start))
|
||||
break
|
||||
}
|
||||
val key = template.substring(start + 2, end)
|
||||
if (key.all { it.isLetterOrDigit() || it == '_' }) {
|
||||
append(valueOf(key, sessionInfo, now))
|
||||
} else {
|
||||
append(template.substring(start, end + 1))
|
||||
}
|
||||
index = end + 1
|
||||
}
|
||||
}.trim()
|
||||
|
||||
|
||||
val entries
|
||||
get() = listOf(
|
||||
Entry("-", null, false),
|
||||
Entry("_", null, false),
|
||||
Entry($$"${YYYY}", "四位年份,例如 2026", true),
|
||||
Entry($$"${YY}", "两位年份,例如 26", true),
|
||||
Entry($$"${MM}", "月份,两位,例如 09", true),
|
||||
Entry($$"${M}", "月份,一位或两位,例如 9", true),
|
||||
Entry($$"${DD}", "日期,两位,例如 19", true),
|
||||
Entry($$"${D}", "日期,一位或两位,例如 9", true),
|
||||
Entry($$"${HH}", "24 小时制小时,两位,例如 23", true),
|
||||
Entry($$"${H}", "24 小时制小时,一位或两位,例如 9", true),
|
||||
Entry($$"${hh}", "12 小时制小时,两位,例如 11", true),
|
||||
Entry($$"${h}", "12 小时制小时,一位或两位,例如 9", true),
|
||||
Entry($$"${mm}", "分钟,两位,例如 09", true),
|
||||
Entry($$"${m}", "分钟,一位或两位,例如 9", true),
|
||||
Entry($$"${SS}", "秒,两位,例如 09", true),
|
||||
Entry($$"${S}", "秒,一位或两位,例如 9", true),
|
||||
Entry($$"${timestamp}", "秒级时间戳,例如 1776952809", true),
|
||||
Entry($$"${deviceName}", "设备名", true),
|
||||
Entry($$"${deviceIp}", "设备 IP", true),
|
||||
Entry($$"${devicePort}", "设备端口", true),
|
||||
Entry($$"${videoCodec}", "视频串流编码(非文件实际编码)", true),
|
||||
Entry($$"${audioCodec}", "音频串流编码(非文件实际编码)", true),
|
||||
Entry($$"${width}", "视频宽度", true),
|
||||
Entry($$"${height}", "视频高度", true),
|
||||
Entry(".mp4", null, false),
|
||||
Entry(".m4a", null, false),
|
||||
Entry(".aac", null, false),
|
||||
Entry(".wav", null, false),
|
||||
)
|
||||
|
||||
private fun valueOf(
|
||||
key: String,
|
||||
sessionInfo: Scrcpy.Session.SessionInfo,
|
||||
now: LocalDateTime,
|
||||
) = when (key) {
|
||||
"YYYY" -> now.year.toString().padStart(4, '0')
|
||||
"YY" -> (now.year % 100).toString().padStart(2, '0')
|
||||
"MM" -> now.monthValue.toString().padStart(2, '0')
|
||||
"M" -> now.monthValue.toString()
|
||||
"DD" -> now.dayOfMonth.toString().padStart(2, '0')
|
||||
"D" -> now.dayOfMonth.toString()
|
||||
"HH" -> now.hour.toString().padStart(2, '0')
|
||||
"H" -> now.hour.toString()
|
||||
"hh" -> (now.hour % 12).let { h -> ((if (h == 0) 12 else h).toString().padStart(2, '0')) }
|
||||
"h" -> (now.hour % 12).let { h -> (if (h == 0) 12 else h).toString() }
|
||||
"mm" -> now.minute.toString().padStart(2, '0')
|
||||
"m" -> now.minute.toString()
|
||||
"SS" -> now.second.toString().padStart(2, '0')
|
||||
"S" -> now.second.toString()
|
||||
"timestamp" -> now.atZone(java.time.ZoneId.systemDefault()).toEpochSecond().toString()
|
||||
"deviceName" -> sessionInfo.deviceName
|
||||
"deviceIp" -> sessionInfo.host
|
||||
"devicePort" -> sessionInfo.port.toString()
|
||||
"videoCodec" -> sessionInfo.codec?.string ?: "unknown"
|
||||
"audioCodec" -> sessionInfo.audioCodec?.string ?: "unknown"
|
||||
"width" -> sessionInfo.width.toString()
|
||||
"height" -> sessionInfo.height.toString()
|
||||
else -> $$"${$$key}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package io.github.miuzarte.scrcpyforandroid.services
|
||||
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
||||
import java.io.File
|
||||
import java.time.LocalDateTime
|
||||
|
||||
object RecordingFileResolver {
|
||||
fun resolve(
|
||||
options: ClientOptions,
|
||||
sessionInfo: Scrcpy.Session.SessionInfo,
|
||||
now: LocalDateTime = LocalDateTime.now(),
|
||||
): File {
|
||||
val resolvedName = RecordFilenameTemplate.resolve(options.recordFilename, sessionInfo, now)
|
||||
val fileName = ensureExtension(
|
||||
sanitizeFileName(resolvedName).ifBlank {
|
||||
throw IllegalArgumentException("Recording filename resolved to blank")
|
||||
},
|
||||
options.recordFormat,
|
||||
)
|
||||
val directory = PublicDirs.recordDirectory()
|
||||
ensureDirectory(directory)
|
||||
return uniqueFile(directory, fileName)
|
||||
}
|
||||
|
||||
private fun ensureExtension(
|
||||
fileName: String,
|
||||
format: ClientOptions.RecordFormat,
|
||||
): String {
|
||||
if (fileName.contains('.')) return fileName
|
||||
val extension = when (format) {
|
||||
ClientOptions.RecordFormat.M4A -> "m4a"
|
||||
ClientOptions.RecordFormat.AAC -> "aac"
|
||||
ClientOptions.RecordFormat.WAV -> "wav"
|
||||
else -> "mp4"
|
||||
}
|
||||
return "$fileName.$extension"
|
||||
}
|
||||
|
||||
fun sanitizeFileName(name: String): String =
|
||||
name.replace(Regex("""[\\/:*?"<>|]"""), "").trim()
|
||||
|
||||
private fun ensureDirectory(directory: File) {
|
||||
if (directory.exists()) return
|
||||
if (!directory.mkdirs()) {
|
||||
throw IllegalStateException("Unable to create directory: ${directory.absolutePath}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun uniqueFile(directory: File, fileName: String): File {
|
||||
val baseName = fileName.substringBeforeLast('.', fileName)
|
||||
val extension = fileName.substringAfterLast('.', "")
|
||||
var index = 0
|
||||
var candidate = File(directory, fileName)
|
||||
while (candidate.exists()) {
|
||||
index += 1
|
||||
val suffix = " ($index)"
|
||||
candidate = if (extension.isBlank()) {
|
||||
File(directory, baseName + suffix)
|
||||
} else {
|
||||
File(directory, "$baseName$suffix.$extension")
|
||||
}
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions.KeyInjectMode
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions.RecordFormat
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.AudioSource
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.CameraFacing
|
||||
@@ -176,6 +177,14 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
|
||||
booleanPreferencesKey("turn_screen_off"),
|
||||
false,
|
||||
)
|
||||
val KEY_INJECT_MODE = Pair(
|
||||
stringPreferencesKey("key_inject_mode"),
|
||||
KeyInjectMode.MIXED.string,
|
||||
)
|
||||
val FORWARD_KEY_REPEAT = Pair(
|
||||
booleanPreferencesKey("forward_key_repeat"),
|
||||
true,
|
||||
)
|
||||
val STAY_AWAKE = Pair(
|
||||
booleanPreferencesKey("stay_awake"),
|
||||
false,
|
||||
@@ -303,6 +312,8 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
|
||||
videoPlayback = VIDEO_PLAYBACK.defaultValue,
|
||||
audioPlayback = AUDIO_PLAYBACK.defaultValue,
|
||||
turnScreenOff = TURN_SCREEN_OFF.defaultValue,
|
||||
keyInjectMode = KEY_INJECT_MODE.defaultValue,
|
||||
forwardKeyRepeat = FORWARD_KEY_REPEAT.defaultValue,
|
||||
stayAwake = STAY_AWAKE.defaultValue,
|
||||
disableScreensaver = DISABLE_SCREENSAVER.defaultValue,
|
||||
powerOffOnClose = POWER_OFF_ON_CLOSE.defaultValue,
|
||||
@@ -367,6 +378,8 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
|
||||
val videoPlayback: Boolean,
|
||||
val audioPlayback: Boolean,
|
||||
val turnScreenOff: Boolean,
|
||||
val keyInjectMode: String,
|
||||
val forwardKeyRepeat: Boolean,
|
||||
val stayAwake: Boolean,
|
||||
val disableScreensaver: Boolean,
|
||||
val powerOffOnClose: Boolean,
|
||||
@@ -430,6 +443,8 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
|
||||
bundleField(VIDEO_PLAYBACK) { it.videoPlayback },
|
||||
bundleField(AUDIO_PLAYBACK) { it.audioPlayback },
|
||||
bundleField(TURN_SCREEN_OFF) { it.turnScreenOff },
|
||||
bundleField(KEY_INJECT_MODE) { it.keyInjectMode },
|
||||
bundleField(FORWARD_KEY_REPEAT) { it.forwardKeyRepeat },
|
||||
bundleField(STAY_AWAKE) { it.stayAwake },
|
||||
bundleField(DISABLE_SCREENSAVER) { it.disableScreensaver },
|
||||
bundleField(POWER_OFF_ON_CLOSE) { it.powerOffOnClose },
|
||||
@@ -493,6 +508,8 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
|
||||
videoPlayback = preferences.read(VIDEO_PLAYBACK),
|
||||
audioPlayback = preferences.read(AUDIO_PLAYBACK),
|
||||
turnScreenOff = preferences.read(TURN_SCREEN_OFF),
|
||||
keyInjectMode = preferences.read(KEY_INJECT_MODE),
|
||||
forwardKeyRepeat = preferences.read(FORWARD_KEY_REPEAT),
|
||||
stayAwake = preferences.read(STAY_AWAKE),
|
||||
disableScreensaver = preferences.read(DISABLE_SCREENSAVER),
|
||||
powerOffOnClose = preferences.read(POWER_OFF_ON_CLOSE),
|
||||
@@ -571,6 +588,8 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
|
||||
videoPlayback = bundle.videoPlayback,
|
||||
audioPlayback = bundle.audioPlayback,
|
||||
turnScreenOff = bundle.turnScreenOff,
|
||||
keyInjectMode = KeyInjectMode.fromString(bundle.keyInjectMode),
|
||||
forwardKeyRepeat = bundle.forwardKeyRepeat,
|
||||
stayAwake = bundle.stayAwake,
|
||||
disableScreensaver = bundle.disableScreensaver,
|
||||
powerOffOnClose = bundle.powerOffOnClose,
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package io.github.miuzarte.scrcpyforandroid.storage
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Parcel
|
||||
import android.util.Base64
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions.Companion.GLOBAL_PROFILE_ID
|
||||
import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions.Companion.GLOBAL_PROFILE_NAME
|
||||
@@ -232,7 +229,7 @@ class ScrcpyProfiles(context: Context) : Settings(context, "ScrcpyProfiles") {
|
||||
Profile(
|
||||
id = id,
|
||||
name = name.ifBlank { "配置" },
|
||||
bundle = decodeBundle(bundleRaw),
|
||||
bundle = decodeBundle(item.optJSONObject("bundle")),
|
||||
isBuiltinGlobal = id == GLOBAL_PROFILE_ID,
|
||||
)
|
||||
)
|
||||
@@ -241,37 +238,147 @@ class ScrcpyProfiles(context: Context) : Settings(context, "ScrcpyProfiles") {
|
||||
return State(profiles)
|
||||
}
|
||||
|
||||
private fun encodeBundle(bundle: ScrcpyOptions.Bundle): String {
|
||||
val parcel = Parcel.obtain()
|
||||
return try {
|
||||
parcel.writeParcelable(bundle, 0)
|
||||
Base64.encodeToString(parcel.marshall(), Base64.NO_WRAP)
|
||||
} finally {
|
||||
parcel.recycle()
|
||||
}
|
||||
private fun encodeBundle(bundle: ScrcpyOptions.Bundle): JSONObject =
|
||||
JSONObject()
|
||||
.put("crop", bundle.crop)
|
||||
.put("recordFilename", bundle.recordFilename)
|
||||
.put("videoCodecOptions", bundle.videoCodecOptions)
|
||||
.put("audioCodecOptions", bundle.audioCodecOptions)
|
||||
.put("videoEncoder", bundle.videoEncoder)
|
||||
.put("audioEncoder", bundle.audioEncoder)
|
||||
.put("cameraId", bundle.cameraId)
|
||||
.put("cameraSize", bundle.cameraSize)
|
||||
.put("cameraSizeCustom", bundle.cameraSizeCustom)
|
||||
.put("cameraSizeUseCustom", bundle.cameraSizeUseCustom)
|
||||
.put("cameraAr", bundle.cameraAr)
|
||||
.put("cameraFps", bundle.cameraFps)
|
||||
.put("logLevel", bundle.logLevel)
|
||||
.put("videoCodec", bundle.videoCodec)
|
||||
.put("audioCodec", bundle.audioCodec)
|
||||
.put("videoSource", bundle.videoSource)
|
||||
.put("audioSource", bundle.audioSource)
|
||||
.put("recordFormat", bundle.recordFormat)
|
||||
.put("cameraFacing", bundle.cameraFacing)
|
||||
.put("maxSize", bundle.maxSize)
|
||||
.put("videoBitRate", bundle.videoBitRate)
|
||||
.put("audioBitRate", bundle.audioBitRate)
|
||||
.put("maxFps", bundle.maxFps)
|
||||
.put("angle", bundle.angle)
|
||||
.put("captureOrientation", bundle.captureOrientation)
|
||||
.put("captureOrientationLock", bundle.captureOrientationLock)
|
||||
.put("displayOrientation", bundle.displayOrientation)
|
||||
.put("recordOrientation", bundle.recordOrientation)
|
||||
.put("displayImePolicy", bundle.displayImePolicy)
|
||||
.put("displayId", bundle.displayId)
|
||||
.put("screenOffTimeout", bundle.screenOffTimeout)
|
||||
.put("showTouches", bundle.showTouches)
|
||||
.put("fullscreen", bundle.fullscreen)
|
||||
.put("control", bundle.control)
|
||||
.put("videoPlayback", bundle.videoPlayback)
|
||||
.put("audioPlayback", bundle.audioPlayback)
|
||||
.put("turnScreenOff", bundle.turnScreenOff)
|
||||
.put("keyInjectMode", bundle.keyInjectMode)
|
||||
.put("forwardKeyRepeat", bundle.forwardKeyRepeat)
|
||||
.put("stayAwake", bundle.stayAwake)
|
||||
.put("disableScreensaver", bundle.disableScreensaver)
|
||||
.put("powerOffOnClose", bundle.powerOffOnClose)
|
||||
.put("legacyPaste", bundle.legacyPaste)
|
||||
.put("clipboardAutosync", bundle.clipboardAutosync)
|
||||
.put("downsizeOnError", bundle.downsizeOnError)
|
||||
.put("mouseHover", bundle.mouseHover)
|
||||
.put("cleanup", bundle.cleanup)
|
||||
.put("powerOn", bundle.powerOn)
|
||||
.put("video", bundle.video)
|
||||
.put("audio", bundle.audio)
|
||||
.put("requireAudio", bundle.requireAudio)
|
||||
.put("killAdbOnClose", bundle.killAdbOnClose)
|
||||
.put("cameraHighSpeed", bundle.cameraHighSpeed)
|
||||
.put("list", bundle.list)
|
||||
.put("audioDup", bundle.audioDup)
|
||||
.put("newDisplay", bundle.newDisplay)
|
||||
.put("startApp", bundle.startApp)
|
||||
.put("startAppCustom", bundle.startAppCustom)
|
||||
.put("startAppUseCustom", bundle.startAppUseCustom)
|
||||
.put("vdDestroyContent", bundle.vdDestroyContent)
|
||||
.put("vdSystemDecorations", bundle.vdSystemDecorations)
|
||||
|
||||
private fun decodeBundle(bundleJson: JSONObject?): ScrcpyOptions.Bundle {
|
||||
val defaults = ScrcpyOptions.defaultBundle()
|
||||
val json = bundleJson ?: return defaults
|
||||
return defaults.copy(
|
||||
crop = json.optStringOrDefault("crop", defaults.crop),
|
||||
recordFilename = json.optStringOrDefault("recordFilename", defaults.recordFilename),
|
||||
videoCodecOptions = json.optStringOrDefault("videoCodecOptions", defaults.videoCodecOptions),
|
||||
audioCodecOptions = json.optStringOrDefault("audioCodecOptions", defaults.audioCodecOptions),
|
||||
videoEncoder = json.optStringOrDefault("videoEncoder", defaults.videoEncoder),
|
||||
audioEncoder = json.optStringOrDefault("audioEncoder", defaults.audioEncoder),
|
||||
cameraId = json.optStringOrDefault("cameraId", defaults.cameraId),
|
||||
cameraSize = json.optStringOrDefault("cameraSize", defaults.cameraSize),
|
||||
cameraSizeCustom = json.optStringOrDefault("cameraSizeCustom", defaults.cameraSizeCustom),
|
||||
cameraSizeUseCustom = json.optBooleanOrDefault("cameraSizeUseCustom", defaults.cameraSizeUseCustom),
|
||||
cameraAr = json.optStringOrDefault("cameraAr", defaults.cameraAr),
|
||||
cameraFps = json.optIntOrDefault("cameraFps", defaults.cameraFps),
|
||||
logLevel = json.optStringOrDefault("logLevel", defaults.logLevel),
|
||||
videoCodec = json.optStringOrDefault("videoCodec", defaults.videoCodec),
|
||||
audioCodec = json.optStringOrDefault("audioCodec", defaults.audioCodec),
|
||||
videoSource = json.optStringOrDefault("videoSource", defaults.videoSource),
|
||||
audioSource = json.optStringOrDefault("audioSource", defaults.audioSource),
|
||||
recordFormat = json.optStringOrDefault("recordFormat", defaults.recordFormat),
|
||||
cameraFacing = json.optStringOrDefault("cameraFacing", defaults.cameraFacing),
|
||||
maxSize = json.optIntOrDefault("maxSize", defaults.maxSize),
|
||||
videoBitRate = json.optIntOrDefault("videoBitRate", defaults.videoBitRate),
|
||||
audioBitRate = json.optIntOrDefault("audioBitRate", defaults.audioBitRate),
|
||||
maxFps = json.optStringOrDefault("maxFps", defaults.maxFps),
|
||||
angle = json.optStringOrDefault("angle", defaults.angle),
|
||||
captureOrientation = json.optIntOrDefault("captureOrientation", defaults.captureOrientation),
|
||||
captureOrientationLock = json.optStringOrDefault("captureOrientationLock", defaults.captureOrientationLock),
|
||||
displayOrientation = json.optIntOrDefault("displayOrientation", defaults.displayOrientation),
|
||||
recordOrientation = json.optIntOrDefault("recordOrientation", defaults.recordOrientation),
|
||||
displayImePolicy = json.optStringOrDefault("displayImePolicy", defaults.displayImePolicy),
|
||||
displayId = json.optIntOrDefault("displayId", defaults.displayId),
|
||||
screenOffTimeout = json.optLongOrDefault("screenOffTimeout", defaults.screenOffTimeout),
|
||||
showTouches = json.optBooleanOrDefault("showTouches", defaults.showTouches),
|
||||
fullscreen = json.optBooleanOrDefault("fullscreen", defaults.fullscreen),
|
||||
control = json.optBooleanOrDefault("control", defaults.control),
|
||||
videoPlayback = json.optBooleanOrDefault("videoPlayback", defaults.videoPlayback),
|
||||
audioPlayback = json.optBooleanOrDefault("audioPlayback", defaults.audioPlayback),
|
||||
turnScreenOff = json.optBooleanOrDefault("turnScreenOff", defaults.turnScreenOff),
|
||||
keyInjectMode = json.optStringOrDefault("keyInjectMode", defaults.keyInjectMode),
|
||||
forwardKeyRepeat = json.optBooleanOrDefault("forwardKeyRepeat", defaults.forwardKeyRepeat),
|
||||
stayAwake = json.optBooleanOrDefault("stayAwake", defaults.stayAwake),
|
||||
disableScreensaver = json.optBooleanOrDefault("disableScreensaver", defaults.disableScreensaver),
|
||||
powerOffOnClose = json.optBooleanOrDefault("powerOffOnClose", defaults.powerOffOnClose),
|
||||
legacyPaste = json.optBooleanOrDefault("legacyPaste", defaults.legacyPaste),
|
||||
clipboardAutosync = json.optBooleanOrDefault("clipboardAutosync", defaults.clipboardAutosync),
|
||||
downsizeOnError = json.optBooleanOrDefault("downsizeOnError", defaults.downsizeOnError),
|
||||
mouseHover = json.optBooleanOrDefault("mouseHover", defaults.mouseHover),
|
||||
cleanup = json.optBooleanOrDefault("cleanup", defaults.cleanup),
|
||||
powerOn = json.optBooleanOrDefault("powerOn", defaults.powerOn),
|
||||
video = json.optBooleanOrDefault("video", defaults.video),
|
||||
audio = json.optBooleanOrDefault("audio", defaults.audio),
|
||||
requireAudio = json.optBooleanOrDefault("requireAudio", defaults.requireAudio),
|
||||
killAdbOnClose = json.optBooleanOrDefault("killAdbOnClose", defaults.killAdbOnClose),
|
||||
cameraHighSpeed = json.optBooleanOrDefault("cameraHighSpeed", defaults.cameraHighSpeed),
|
||||
list = json.optStringOrDefault("list", defaults.list),
|
||||
audioDup = json.optBooleanOrDefault("audioDup", defaults.audioDup),
|
||||
newDisplay = json.optStringOrDefault("newDisplay", defaults.newDisplay),
|
||||
startApp = json.optStringOrDefault("startApp", defaults.startApp),
|
||||
startAppCustom = json.optStringOrDefault("startAppCustom", defaults.startAppCustom),
|
||||
startAppUseCustom = json.optBooleanOrDefault("startAppUseCustom", defaults.startAppUseCustom),
|
||||
vdDestroyContent = json.optBooleanOrDefault("vdDestroyContent", defaults.vdDestroyContent),
|
||||
vdSystemDecorations = json.optBooleanOrDefault("vdSystemDecorations", defaults.vdSystemDecorations),
|
||||
)
|
||||
}
|
||||
|
||||
private fun decodeBundle(raw: String): ScrcpyOptions.Bundle {
|
||||
if (raw.isBlank()) return ScrcpyOptions.defaultBundle()
|
||||
val bytes = runCatching { Base64.decode(raw, Base64.DEFAULT) }.getOrNull()
|
||||
?: return ScrcpyOptions.defaultBundle()
|
||||
val parcel = Parcel.obtain()
|
||||
return try {
|
||||
parcel.unmarshall(bytes, 0, bytes.size)
|
||||
parcel.setDataPosition(0)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
parcel.readParcelable(
|
||||
ScrcpyOptions.Bundle::class.java.classLoader,
|
||||
ScrcpyOptions.Bundle::class.java,
|
||||
)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
parcel.readParcelable(ScrcpyOptions.Bundle::class.java.classLoader)
|
||||
} ?: ScrcpyOptions.defaultBundle()
|
||||
} catch (_: Throwable) {
|
||||
ScrcpyOptions.defaultBundle()
|
||||
} finally {
|
||||
parcel.recycle()
|
||||
}
|
||||
}
|
||||
private fun JSONObject.optStringOrDefault(key: String, defaultValue: String): String =
|
||||
if (has(key) && !isNull(key)) optString(key, defaultValue) else defaultValue
|
||||
|
||||
private fun JSONObject.optBooleanOrDefault(key: String, defaultValue: Boolean): Boolean =
|
||||
if (has(key) && !isNull(key)) optBoolean(key, defaultValue) else defaultValue
|
||||
|
||||
private fun JSONObject.optIntOrDefault(key: String, defaultValue: Int): Int =
|
||||
if (has(key) && !isNull(key)) optInt(key, defaultValue) else defaultValue
|
||||
|
||||
private fun JSONObject.optLongOrDefault(key: String, defaultValue: Long): Long =
|
||||
if (has(key) && !isNull(key)) optLong(key, defaultValue) else defaultValue
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package io.github.miuzarte.scrcpyforandroid.ui
|
||||
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedback
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
|
||||
fun HapticFeedback.confirm() =
|
||||
performHapticFeedback(HapticFeedbackType.Confirm)
|
||||
|
||||
fun HapticFeedback.contextClick() =
|
||||
performHapticFeedback(HapticFeedbackType.ContextClick)
|
||||
|
||||
fun HapticFeedback.gestureEnd() =
|
||||
performHapticFeedback(HapticFeedbackType.GestureEnd)
|
||||
|
||||
fun HapticFeedback.gestureThresholdActivate() =
|
||||
performHapticFeedback(HapticFeedbackType.GestureThresholdActivate)
|
||||
|
||||
fun HapticFeedback.keyboardTap() =
|
||||
performHapticFeedback(HapticFeedbackType.KeyboardTap)
|
||||
|
||||
fun HapticFeedback.longPress() =
|
||||
performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
|
||||
fun HapticFeedback.reject() =
|
||||
performHapticFeedback(HapticFeedbackType.Reject)
|
||||
|
||||
fun HapticFeedback.segmentFrequentTick() =
|
||||
performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
|
||||
|
||||
fun HapticFeedback.segmentTick() =
|
||||
performHapticFeedback(HapticFeedbackType.SegmentTick)
|
||||
|
||||
fun HapticFeedback.textHandleMove() =
|
||||
performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
|
||||
fun HapticFeedback.toggleOff() =
|
||||
performHapticFeedback(HapticFeedbackType.ToggleOff)
|
||||
|
||||
fun HapticFeedback.toggleOn() =
|
||||
performHapticFeedback(HapticFeedbackType.ToggleOn)
|
||||
|
||||
fun HapticFeedback.virtualKey() =
|
||||
performHapticFeedback(HapticFeedbackType.VirtualKey)
|
||||
@@ -54,10 +54,10 @@ import androidx.compose.ui.focus.FocusDirection
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
@@ -69,8 +69,7 @@ import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
|
||||
import io.github.miuzarte.scrcpyforandroid.constants.Defaults
|
||||
import io.github.miuzarte.scrcpyforandroid.constants.ScrcpyPresets
|
||||
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
|
||||
import io.github.miuzarte.scrcpyforandroid.haptics.LocalAppHaptics
|
||||
import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
|
||||
import io.github.miuzarte.scrcpyforandroid.ui.contextClick
|
||||
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut
|
||||
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlider
|
||||
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField
|
||||
@@ -263,7 +262,7 @@ internal fun PreviewCard(
|
||||
autoBringIntoView: Boolean = false,
|
||||
onAutoBringIntoViewConsumed: () -> Unit = {},
|
||||
) {
|
||||
val haptics = rememberAppHaptics()
|
||||
val haptic = LocalHapticFeedback.current
|
||||
|
||||
var previewControlsVisible by rememberSaveable { mutableStateOf(false) }
|
||||
val alpha by animateFloatAsState(
|
||||
@@ -359,7 +358,7 @@ internal fun PreviewCard(
|
||||
Button(
|
||||
onClick = {
|
||||
if (alpha > 0.1f) {
|
||||
haptics.contextClick()
|
||||
haptic.contextClick()
|
||||
onOpenFullscreen()
|
||||
}
|
||||
},
|
||||
@@ -1028,7 +1027,7 @@ internal fun DeviceTile(
|
||||
onEditorDelete: () -> Unit,
|
||||
onEditorCancel: () -> Unit,
|
||||
) {
|
||||
val haptics = rememberAppHaptics()
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val snackbar = LocalSnackbarController.current
|
||||
val scrcpyProfilesState by Storage.scrcpyProfiles.state.collectAsState()
|
||||
|
||||
@@ -1083,7 +1082,7 @@ internal fun DeviceTile(
|
||||
else colorScheme.surfaceContainer.copy(alpha = 0.6f),
|
||||
),
|
||||
pressFeedbackType = if (!editing) PressFeedbackType.Sink else PressFeedbackType.None,
|
||||
onClick = haptics.contextClick,
|
||||
onClick = haptic::contextClick,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -1302,7 +1301,7 @@ internal fun QuickConnectCard(
|
||||
onAddDevice: () -> Unit,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
val haptics = LocalAppHaptics.current
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
Card(
|
||||
@@ -1311,7 +1310,7 @@ internal fun QuickConnectCard(
|
||||
if (enabled) PressFeedbackType.Tilt
|
||||
else PressFeedbackType.None,
|
||||
insideMargin = PaddingValues(UiSpacing.Content),
|
||||
onClick = haptics.contextClick,
|
||||
onClick = haptic::contextClick,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical)) {
|
||||
Row(
|
||||
|
||||
@@ -49,8 +49,8 @@ fun MultiGroupsDropdownPreference(
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val isExpanded = remember { mutableStateOf(false) }
|
||||
val isHoldDown = remember { mutableStateOf(false) }
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
val currentHapticFeedback by rememberUpdatedState(hapticFeedback)
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val hapticLatest by rememberUpdatedState(haptic)
|
||||
|
||||
val hasOptions = groups.any { it.options.isNotEmpty() }
|
||||
val actualEnabled = enabled && hasOptions
|
||||
@@ -104,7 +104,7 @@ fun MultiGroupsDropdownPreference(
|
||||
isExpanded.value = !isExpanded.value
|
||||
if (isExpanded.value) {
|
||||
isHoldDown.value = true
|
||||
currentHapticFeedback.performHapticFeedback(HapticFeedbackType.ContextClick)
|
||||
hapticLatest.performHapticFeedback(HapticFeedbackType.ContextClick)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package io.github.miuzarte.scrcpyforandroid.widgets
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import io.github.miuzarte.scrcpyforandroid.pages.LocalRootNavigator
|
||||
import io.github.miuzarte.scrcpyforandroid.pages.RootScreen
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions.RecordFormat
|
||||
import io.github.miuzarte.scrcpyforandroid.services.NativeRecordingSupport
|
||||
import top.yukonga.miuix.kmp.basic.Text
|
||||
import top.yukonga.miuix.kmp.preference.ArrowPreference
|
||||
import top.yukonga.miuix.kmp.preference.OverlayDropdownPreference
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme.textStyles
|
||||
|
||||
@Composable
|
||||
fun RecordPreferences(
|
||||
profileId: String,
|
||||
recordFilenameTemplate: String,
|
||||
recordFormat: String,
|
||||
enabled: Boolean,
|
||||
onRecordFormatChange: (String) -> Unit,
|
||||
) {
|
||||
val navigator = LocalRootNavigator.current
|
||||
val supportedFormats = remember { NativeRecordingSupport.supportedFormats }
|
||||
|
||||
val formatItems = remember {
|
||||
supportedFormats.map {
|
||||
if (it == RecordFormat.AUTO) "自动"
|
||||
else it.string
|
||||
}
|
||||
}
|
||||
val formatIndex = remember(recordFormat) {
|
||||
supportedFormats.indexOfFirst { it.string == recordFormat }
|
||||
.coerceAtLeast(0)
|
||||
}
|
||||
val currentTemplateSummary = recordFilenameTemplate.ifBlank { "关闭" }
|
||||
|
||||
ArrowPreference(
|
||||
title = "录制",
|
||||
summary = "--record",
|
||||
enabled = enabled,
|
||||
onClick = {
|
||||
navigator.push(RootScreen.ScrcpyOptionRecord(profileId))
|
||||
},
|
||||
endActions = {
|
||||
Text(
|
||||
text = currentTemplateSummary,
|
||||
color = colorScheme.onSurfaceVariantActions,
|
||||
fontSize = textStyles.body2.fontSize,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
OverlayDropdownPreference(
|
||||
title = "录制格式",
|
||||
summary = "--record-format",
|
||||
items = formatItems,
|
||||
selectedIndex = formatIndex,
|
||||
enabled = enabled,
|
||||
onSelectedIndexChange = { index ->
|
||||
onRecordFormatChange(supportedFormats[index].string)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -20,12 +20,13 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
|
||||
import io.github.miuzarte.scrcpyforandroid.haptics.LocalAppHaptics
|
||||
import io.github.miuzarte.scrcpyforandroid.ui.contextClick
|
||||
import top.yukonga.miuix.kmp.basic.Card
|
||||
import top.yukonga.miuix.kmp.basic.CardDefaults.defaultColors
|
||||
import top.yukonga.miuix.kmp.basic.Icon
|
||||
@@ -67,7 +68,8 @@ internal fun StatusCardLayout(
|
||||
spec: StatusCardSpec,
|
||||
busyLabel: String?,
|
||||
) {
|
||||
val haptics = LocalAppHaptics.current
|
||||
val haptic = LocalHapticFeedback.current
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -81,7 +83,7 @@ internal fun StatusCardLayout(
|
||||
.fillMaxHeight(),
|
||||
colors = defaultColors(color = spec.big.containerColor),
|
||||
pressFeedbackType = PressFeedbackType.Tilt,
|
||||
onClick = haptics.contextClick,
|
||||
onClick = haptic::contextClick,
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Box(
|
||||
@@ -153,12 +155,12 @@ internal fun StatusCardLayout(
|
||||
|
||||
@Composable
|
||||
private fun StatusMetricCard(spec: StatusSmallCardSpec, modifier: Modifier) {
|
||||
val haptics = LocalAppHaptics.current
|
||||
val haptic = LocalHapticFeedback.current
|
||||
Card(
|
||||
modifier = modifier,
|
||||
insideMargin = PaddingValues(UiSpacing.Large),
|
||||
pressFeedbackType = PressFeedbackType.Tilt,
|
||||
onClick = haptics.contextClick,
|
||||
onClick = haptic::contextClick,
|
||||
) {
|
||||
Text(
|
||||
text = spec.title,
|
||||
|
||||
@@ -50,12 +50,14 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.github.miuzarte.scrcpyforandroid.constants.UiAndroidKeycodes
|
||||
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
|
||||
import io.github.miuzarte.scrcpyforandroid.haptics.LocalAppHaptics
|
||||
import io.github.miuzarte.scrcpyforandroid.ui.confirm
|
||||
import io.github.miuzarte.scrcpyforandroid.ui.contextClick
|
||||
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
|
||||
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -253,13 +255,15 @@ class VirtualButtonBar(
|
||||
modifier: Modifier = Modifier,
|
||||
passwordPopupContent: (@Composable (onDismissRequest: () -> Unit) -> Unit)? = null,
|
||||
) {
|
||||
val haptics = LocalAppHaptics.current
|
||||
val haptic = LocalHapticFeedback.current
|
||||
|
||||
val activeContainerColor = colorScheme.primary
|
||||
val disabledContainerColor = colorScheme.primary.copy(alpha = 0.35f)
|
||||
val activeContentColor = colorScheme.onPrimary
|
||||
val disabledContentColor = colorScheme.onPrimary.copy(alpha = 0.45f)
|
||||
|
||||
var showMorePopup by remember { mutableStateOf(false) }
|
||||
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
|
||||
@@ -269,7 +273,7 @@ class VirtualButtonBar(
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
Button(
|
||||
onClick = {
|
||||
haptics.contextClick()
|
||||
haptic.contextClick()
|
||||
when (action) {
|
||||
VirtualButtonAction.MORE -> {
|
||||
showMorePopup = true
|
||||
@@ -348,7 +352,7 @@ class VirtualButtonBar(
|
||||
passwordPopupContent: (@Composable (onDismissRequest: () -> Unit) -> Unit)? = null,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val haptics = LocalAppHaptics.current
|
||||
val haptic = LocalHapticFeedback.current
|
||||
var showMorePopup by remember { mutableStateOf(false) }
|
||||
var showPasswordPopup by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -379,7 +383,7 @@ class VirtualButtonBar(
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
Button(
|
||||
onClick = {
|
||||
haptics.contextClick()
|
||||
haptic.contextClick()
|
||||
when (action) {
|
||||
VirtualButtonAction.MORE -> {
|
||||
showMorePopup = true
|
||||
@@ -432,7 +436,7 @@ class VirtualButtonBar(
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
Button(
|
||||
onClick = {
|
||||
haptics.contextClick()
|
||||
haptic.contextClick()
|
||||
when (action) {
|
||||
VirtualButtonAction.MORE -> {
|
||||
showMorePopup = true
|
||||
@@ -501,7 +505,7 @@ class VirtualButtonBar(
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
|
||||
val haptics = LocalAppHaptics.current
|
||||
val haptic = LocalHapticFeedback.current
|
||||
var showActions by remember { mutableStateOf(false) }
|
||||
var showPasswordPopup by remember { mutableStateOf(false) }
|
||||
val asBundleShared by appSettings.bundleState.collectAsState()
|
||||
@@ -592,7 +596,7 @@ class VirtualButtonBar(
|
||||
Button(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onClick = {
|
||||
haptics.contextClick()
|
||||
haptic.contextClick()
|
||||
showActions = true
|
||||
},
|
||||
cornerRadius = ballSize / 2,
|
||||
@@ -664,7 +668,7 @@ class VirtualButtonBar(
|
||||
popupAlignment: PopupPositionProvider.Align = PopupPositionProvider.Align.TopEnd,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val haptics = LocalAppHaptics.current
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val spinnerItems = remember(actions) {
|
||||
actions.map { action ->
|
||||
SpinnerEntry(
|
||||
@@ -699,7 +703,7 @@ class VirtualButtonBar(
|
||||
spinnerColors = SpinnerDefaults.spinnerColors(),
|
||||
dialogMode = false,
|
||||
onSelectedIndexChange = { selectedIdx ->
|
||||
haptics.confirm()
|
||||
haptic.confirm()
|
||||
val selectedAction = actions[selectedIdx]
|
||||
if (
|
||||
selectedAction == VirtualButtonAction.PASSWORD_INPUT &&
|
||||
|
||||
Reference in New Issue
Block a user