diff --git a/.gitignore b/.gitignore index 986a919..62cdb84 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,5 @@ google-services.json .kotlin/ scrcpy.help* + +*.mp4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 900adb4..c2d1653 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ - 改进: 设备页连接设备后选项卡显示当前配置名 - 修复: 正确实现 `--disable-screensaver`,`FLAG_KEEP_SCREEN_ON` 而非 `PARTIAL_WAKE_LOCK` +- 新增: `--record`, `--record-format` +- 重构: adb / scrcpy 连接断开逻辑、状态管理 +- 修复: 更新不再会重置自定义增加的 Scrcpy 参数配置档 +- 新增: `--new-display` 快捷填入本机数值 ## 0.2.1 diff --git a/README.md b/README.md index 3a2002b..d66a038 100644 --- a/README.md +++ b/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+ diff --git a/TODO.md b/TODO.md index e8f4a7c..129f7a6 100644 --- a/TODO.md +++ b/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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ca72871..0d74acb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 { diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/LockscreenPasswordActivity.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/LockscreenPasswordActivity.kt index d2f3e59..8bf8297 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/LockscreenPasswordActivity.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/LockscreenPasswordActivity.kt @@ -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, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt index f2ff8aa..d0f32b0 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt @@ -30,6 +30,8 @@ object NativeCoreFacade { private val mainHandler = Handler(Looper.getMainLooper()) private val bootstrapLock = Any() private val bootstrapPackets = ArrayDeque() + @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" diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/haptics/AppHaptics.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/haptics/AppHaptics.kt deleted file mode 100644 index aa1e921..0000000 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/haptics/AppHaptics.kt +++ /dev/null @@ -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 { - 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() }, - ) - } -} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/PersistentVideoRenderer.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/PersistentVideoRenderer.kt index e3d61f9..b454744 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/PersistentVideoRenderer.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/PersistentVideoRenderer.kt @@ -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 { diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt index 73277c5..6f766be 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt @@ -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 }, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt index cf55591..54e2500 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt @@ -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, + ) }, ) } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ImeTextSubmission.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ImeTextSubmission.kt index 88f493d..9f21446 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ImeTextSubmission.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ImeTextSubmission.kt @@ -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) + } + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt index e9cd640..fedf1d5 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt @@ -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 { 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, ) { diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/RecordPreferencesScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/RecordPreferencesScreen.kt new file mode 100644 index 0000000..bab4951 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/RecordPreferencesScreen.kt @@ -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, + ) +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt index 0bfe2e0..7d28db6 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt @@ -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, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt index f7dd852..8b54410 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt @@ -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( diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/StreamScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/StreamScreen.kt index 0efb137..57cd95f 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/StreamScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/StreamScreen.kt @@ -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, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/ReorderableList.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/ReorderableList.kt index 87667b7..869361a 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/ReorderableList.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/ReorderableList.kt @@ -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( diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSlider.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSlider.kt index f3bf477..7e03c8a 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSlider.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSlider.kt @@ -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, @@ -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, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSpinner.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSpinner.kt index 819205c..7381e8b 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSpinner.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSpinner.kt @@ -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() } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ClientOptions.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ClientOptions.kt index dc59fa2..8044c09 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ClientOptions.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ClientOptions.kt @@ -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, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt index 5462a1f..bfa925f 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt @@ -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, ) diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/AppRuntime.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/AppRuntime.kt index a55bd79..fd5c350 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/AppRuntime.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/AppRuntime.kt @@ -19,4 +19,5 @@ object AppRuntime { var scrcpy: Scrcpy? = null var currentConnectionTarget: ConnectionTarget? = null + var currentConnectedDevice: ConnectedDeviceInfo? = null } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/ConnectionController.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/ConnectionController.kt new file mode 100644 index 0000000..9df71e7 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/ConnectionController.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/ConnectionStateStore.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/ConnectionStateStore.kt new file mode 100644 index 0000000..937366d --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/ConnectionStateStore.kt @@ -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 = _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, + ) + } + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceAdbAutoReconnectManager.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceAdbAutoReconnectManager.kt new file mode 100644 index 0000000..b61a733 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceAdbAutoReconnectManager.kt @@ -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, + isBlacklisted: (String) -> Boolean, + connectTimeoutMs: Long, + probeTimeoutMs: Int, + discoverConnectService: suspend () -> Pair?, + 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() + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceAdbConnectionCoordinator.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceAdbConnectionCoordinator.kt index b9f4e90..bb60f5f 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceAdbConnectionCoordinator.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceAdbConnectionCoordinator.kt @@ -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? { - 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? { - 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, + ) + } } } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceConnectionLogic.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceConnectionLogic.kt index 27fb2cb..2cab3f9 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceConnectionLogic.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceConnectionLogic.kt @@ -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, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/FileManagerService.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/FileManagerService.kt index 0126956..a1d9c33 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/FileManagerService.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/FileManagerService.kt @@ -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) diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/NativeAacRecorder.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/NativeAacRecorder.kt new file mode 100644 index 0000000..2c847aa --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/NativeAacRecorder.kt @@ -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 + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/NativeAudioRecorder.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/NativeAudioRecorder.kt new file mode 100644 index 0000000..1d393e5 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/NativeAudioRecorder.kt @@ -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 + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/NativeMp4Recorder.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/NativeMp4Recorder.kt new file mode 100644 index 0000000..dfc3e8c --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/NativeMp4Recorder.kt @@ -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() + + 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 + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/NativeRecordingSupport.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/NativeRecordingSupport.kt new file mode 100644 index 0000000..19bf5a8 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/NativeRecordingSupport.kt @@ -0,0 +1,17 @@ +package io.github.miuzarte.scrcpyforandroid.services + +import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions.RecordFormat + +object NativeRecordingSupport { + val supportedFormats: List = listOf( + RecordFormat.AUTO, + RecordFormat.MP4, + RecordFormat.M4A, + RecordFormat.AAC, + RecordFormat.WAV, + ) + + fun isSupported(format: RecordFormat): Boolean { + return format in supportedFormats + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/NativeWavRecorder.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/NativeWavRecorder.kt new file mode 100644 index 0000000..8cdd274 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/NativeWavRecorder.kt @@ -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 + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/PublicDirs.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/PublicDirs.kt new file mode 100644 index 0000000..3675f84 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/PublicDirs.kt @@ -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 + ) + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/RecordFilenameTemplate.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/RecordFilenameTemplate.kt new file mode 100644 index 0000000..45ec7cd --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/RecordFilenameTemplate.kt @@ -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}" + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/RecordingFileResolver.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/RecordingFileResolver.kt new file mode 100644 index 0000000..be251fd --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/RecordingFileResolver.kt @@ -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 + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt index 38bb9f9..4724999 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt @@ -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, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyProfiles.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyProfiles.kt index 32f1abe..2079dea 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyProfiles.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyProfiles.kt @@ -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 } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/ui/Haptic.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/ui/Haptic.kt new file mode 100644 index 0000000..64b214c --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/ui/Haptic.kt @@ -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) diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt index 36e5198..dfcc0d8 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt @@ -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( diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/MultiGroupsDropdown.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/MultiGroupsDropdown.kt index c6d62b5..4951708 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/MultiGroupsDropdown.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/MultiGroupsDropdown.kt @@ -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) } } }, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/RecordPreferences.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/RecordPreferences.kt new file mode 100644 index 0000000..bb7ede3 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/RecordPreferences.kt @@ -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) + }, + ) +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/StatusCardModels.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/StatusCardModels.kt index c9be2e5..b446382 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/StatusCardModels.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/StatusCardModels.kt @@ -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, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt index fac4389..f074c45 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt @@ -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 &&