feat: native recording

This commit is contained in:
Miuzarte
2026-04-23 18:45:17 +08:00
parent c2bf024d48
commit e263113990
45 changed files with 3609 additions and 600 deletions

View File

@@ -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,

View File

@@ -30,6 +30,8 @@ object NativeCoreFacade {
private val mainHandler = Handler(Looper.getMainLooper())
private val bootstrapLock = Any()
private val bootstrapPackets = ArrayDeque<CachedPacket>()
@Volatile
private var recordingSurfaceAttached = false
@Volatile
private var latestConfigPacket: CachedPacket? = null
@@ -109,6 +111,39 @@ object NativeCoreFacade {
Log.i(TAG, "detachVideoSurface(): releasing decoder with destroyed surface")
decoder?.release()
decoder = null
} else if (activeSurfaceId == null && !recordingSurfaceAttached) {
decoder?.release()
decoder = null
}
}
}
suspend fun attachRecordingSurface(
surface: Surface,
width: Int,
height: Int,
onFrameRendered: ((Long) -> Unit)? = null,
) {
sessionLifecycleMutex.withLock {
recordingSurfaceAttached = true
renderer.attachRecordSurface(surface, width, height, onFrameRendered)
val session = currentSessionInfo
if (session != null && decoder == null) {
createOrReplaceDecoder(session)
}
}
}
suspend fun detachRecordingSurface(
surface: Surface? = null,
releaseSurface: Boolean = false,
) {
sessionLifecycleMutex.withLock {
recordingSurfaceAttached = false
renderer.detachRecordSurface(surface, releaseSurface)
if (activeSurfaceId == null) {
decoder?.release()
decoder = null
}
}
}
@@ -132,7 +167,7 @@ object NativeCoreFacade {
bootstrapPackets.clear()
latestConfigPacket = null
}
if (activeSurfaceId != null) {
if (activeSurfaceId != null || recordingSurfaceAttached) {
Log.i(TAG, "onScrcpySessionStarted(): bind decoder to persistent surface")
createOrReplaceDecoder(session)
}
@@ -170,6 +205,7 @@ object NativeCoreFacade {
latestConfigPacket = null
}
currentSessionInfo = null
recordingSurfaceAttached = false
}
private const val TAG = "NativeCoreFacade"

View File

@@ -1,98 +0,0 @@
package io.github.miuzarte.scrcpyforandroid.haptics
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
@Composable
fun performHaptics(type: HapticFeedbackType): Unit {
LocalHapticFeedback.current.performHapticFeedback(type)
}
@Immutable
data class AppHaptics(
val confirm: () -> Unit,
val contextClick: () -> Unit,
val gestureEnd: () -> Unit,
val gestureThresholdActivate: () -> Unit,
val keyboardTap: () -> Unit,
val longPress: () -> Unit,
val reject: () -> Unit,
val segmentFrequentTick: () -> Unit,
val segmentTick: () -> Unit,
val textHandleMove: () -> Unit,
val toggleOff: () -> Unit,
val toggleOn: () -> Unit,
val virtualKey: () -> Unit,
)
val LocalAppHaptics = staticCompositionLocalOf<AppHaptics> {
error("No AppHaptics provided")
}
@Composable
fun rememberAppHaptics(): AppHaptics {
val hapticFeedback = LocalHapticFeedback.current
val performConfirm = rememberUpdatedState {
hapticFeedback.performHapticFeedback(HapticFeedbackType.Confirm)
}
val performContextClick = rememberUpdatedState {
hapticFeedback.performHapticFeedback(HapticFeedbackType.ContextClick)
}
val performGestureEnd = rememberUpdatedState {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureEnd)
}
val performGestureThresholdActivate = rememberUpdatedState {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate)
}
val performKeyboardTan = rememberUpdatedState {
hapticFeedback.performHapticFeedback(HapticFeedbackType.KeyboardTap)
}
val performLongPress = rememberUpdatedState {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
val performReject = rememberUpdatedState {
hapticFeedback.performHapticFeedback(HapticFeedbackType.Reject)
}
val performSegmentFrequentTick = rememberUpdatedState {
hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
}
val performSegmentTick = rememberUpdatedState {
hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentTick)
}
val performTextHandleMove = rememberUpdatedState {
hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove)
}
val performToggleOff = rememberUpdatedState {
hapticFeedback.performHapticFeedback(HapticFeedbackType.ToggleOff)
}
val performToggleOn = rememberUpdatedState {
hapticFeedback.performHapticFeedback(HapticFeedbackType.ToggleOn)
}
val performVirtualKey = rememberUpdatedState {
hapticFeedback.performHapticFeedback(HapticFeedbackType.VirtualKey)
}
return remember {
AppHaptics(
confirm = { performConfirm.value.invoke() },
contextClick = { performContextClick.value.invoke() },
gestureEnd = { performGestureEnd.value.invoke() },
gestureThresholdActivate = { performGestureThresholdActivate.value.invoke() },
keyboardTap = { performKeyboardTan.value.invoke() },
longPress = { performLongPress.value.invoke() },
reject = { performReject.value.invoke() },
segmentFrequentTick = { performSegmentFrequentTick.value.invoke() },
segmentTick = { performSegmentTick.value.invoke() },
textHandleMove = { performTextHandleMove.value.invoke() },
toggleOff = { performToggleOff.value.invoke() },
toggleOn = { performToggleOn.value.invoke() },
virtualKey = { performVirtualKey.value.invoke() },
)
}
}

View File

@@ -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 {

View File

@@ -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 },

View File

@@ -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,
)
},
)
}

View File

@@ -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)
}
}
}

View File

@@ -60,12 +60,15 @@ import io.github.miuzarte.scrcpyforandroid.BuildConfig
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
import io.github.miuzarte.scrcpyforandroid.constants.ThemeModes
import io.github.miuzarte.scrcpyforandroid.constants.UiMotion
import io.github.miuzarte.scrcpyforandroid.haptics.LocalAppHaptics
import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.services.AppScreenOn
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
import io.github.miuzarte.scrcpyforandroid.services.AppUpdateChecker
import io.github.miuzarte.scrcpyforandroid.services.ConnectionController
import io.github.miuzarte.scrcpyforandroid.services.ConnectionStateStore
import io.github.miuzarte.scrcpyforandroid.services.DeviceAdbAutoReconnectManager
import io.github.miuzarte.scrcpyforandroid.services.DeviceAdbConnectionCoordinator
import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
import io.github.miuzarte.scrcpyforandroid.services.SnackbarController
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
@@ -147,6 +150,7 @@ sealed interface RootScreen : NavKey {
data object Advanced : RootScreen
data object About : RootScreen
data object VirtualButtonOrder : RootScreen
data class ScrcpyOptionRecord(val profileId: String) : RootScreen
}
@Composable
@@ -168,7 +172,6 @@ fun MainScreen() {
hostState = snackHostState,
)
}
val haptics = rememberAppHaptics()
// Root navigation and UI chrome state
val saveableStateHolder = rememberSaveableStateHolder()
@@ -202,6 +205,7 @@ fun MainScreen() {
when (currentRootScreen) {
is RootScreen.Advanced -> true
is RootScreen.VirtualButtonOrder -> true
is RootScreen.ScrcpyOptionRecord -> true
else -> false
}
})
@@ -291,6 +295,31 @@ fun MainScreen() {
}
val currentSession by scrcpy.currentSessionState.collectAsState()
val deviceConnectionServices = remember(scrcpy) {
val adbCoordinator = DeviceAdbConnectionCoordinator()
val connectionStateStore = ConnectionStateStore()
val connectionController = ConnectionController(
scrcpy = scrcpy,
stateStore = connectionStateStore,
adbCoordinator = adbCoordinator,
)
val autoReconnectManager = DeviceAdbAutoReconnectManager(
controller = connectionController,
stateStore = connectionStateStore,
)
DeviceConnectionServices(
adbCoordinator = adbCoordinator,
connectionStateStore = connectionStateStore,
connectionController = connectionController,
autoReconnectManager = autoReconnectManager,
)
}
DisposableEffect(deviceConnectionServices) {
onDispose {
deviceConnectionServices.autoReconnectManager.close()
AppScreenOn.release()
}
}
// Side-effect launchers and composition locals
val picker = rememberLauncherForActivityResult(
@@ -530,6 +559,7 @@ fun MainScreen() {
MainBottomTabDestination.Devices -> DeviceTabScreen(
scrollBehavior = devicesPageScrollBehavior,
scrcpy = scrcpy,
connectionServices = deviceConnectionServices,
bottomInnerPadding = bottomInnerPadding,
onOpenReorderDevices = { showReorderDevices = true },
)
@@ -625,6 +655,14 @@ fun MainScreen() {
)
}
entry<RootScreen.ScrcpyOptionRecord> { route ->
RecordPreferencesScreen(
scrollBehavior = advancedPageScrollBehavior,
profileId = route.profileId,
scrcpy = scrcpy,
)
}
}
val rootEntries = rememberDecoratedNavEntries(
@@ -649,7 +687,6 @@ fun MainScreen() {
LocalEnableFloatingBottomBarBlur provides asBundle.floatingBottomBarBlur,
LocalRootNavigator provides rootNavigator,
LocalSnackbarController provides snackbarController,
LocalAppHaptics provides haptics,
LocalServerPicker provides serverPicker,
LocalTerminalFontPicker provides terminalFontPicker,
) {

View File

@@ -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,
)
}

View File

@@ -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,

View File

@@ -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(

View File

@@ -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,

View File

@@ -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(

View File

@@ -28,7 +28,7 @@ import top.yukonga.miuix.kmp.theme.MiuixTheme.textStyles
@Composable
fun SuperSlider(
title: String,
summary: String,
summary: String? = null,
value: Float,
onValueChange: (Float) -> Unit,
valueRange: ClosedFloatingPointRange<Float>,
@@ -42,7 +42,7 @@ fun SuperSlider(
displayFormatter: (Float) -> String = { it.toInt().toString() },
displayText: String? = null,
inputTitle: String = title,
inputSummary: String = summary,
inputSummary: String? = summary,
inputLabel: String = unit,
useLabelAsPlaceholder: Boolean = false,
inputInitialValue: String = displayFormatter(value),
@@ -110,7 +110,7 @@ fun SuperSlider(
private fun SliderInputDialog(
showDialog: Boolean,
title: String,
summary: String,
summary: String? = null,
label: String = "",
useLabelAsPlaceholder: Boolean = false,
initialValue: String,

View File

@@ -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()
}

View File

@@ -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,

View File

@@ -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,
)

View File

@@ -19,4 +19,5 @@ object AppRuntime {
var scrcpy: Scrcpy? = null
var currentConnectionTarget: ConnectionTarget? = null
var currentConnectedDevice: ConnectedDeviceInfo? = null
}

View File

@@ -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,
)
}
}

View File

@@ -0,0 +1,84 @@
package io.github.miuzarte.scrcpyforandroid.services
import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget
import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
internal enum class DisconnectCause {
User,
KeepAliveFailed,
SwitchTarget,
KillAdbOnClose,
ConnectFailed,
AutoReconnectFailed,
Unknown,
}
internal data class ConnectionState(
val adbSession: DeviceAdbSessionState = DeviceAdbSessionState(),
val disconnectCause: DisconnectCause? = null,
val lastError: String? = null,
)
internal class ConnectionStateStore {
private val _state = MutableStateFlow(ConnectionState())
val state: StateFlow<ConnectionState> = _state.asStateFlow()
fun update(transform: (ConnectionState) -> ConnectionState) {
_state.update(transform)
}
fun updateSession(transform: (DeviceAdbSessionState) -> DeviceAdbSessionState) {
_state.update { current ->
current.copy(adbSession = transform(current.adbSession))
}
}
fun markConnected(
target: ConnectionTarget,
scrcpyProfileId: String = ScrcpyOptions.GLOBAL_PROFILE_ID,
) {
update {
it.copy(
adbSession = it.adbSession.copy(
isConnected = true,
currentTarget = target,
connectedScrcpyProfileId = scrcpyProfileId,
statusLine = "${target.host}:${target.port}",
),
disconnectCause = null,
lastError = null,
)
}
}
fun markDisconnected(
cause: DisconnectCause,
statusLine: String = "未连接",
connectedDeviceLabel: String = "未连接",
) {
update {
it.copy(
adbSession = DeviceAdbSessionState(
statusLine = statusLine,
connectedDeviceLabel = connectedDeviceLabel,
),
disconnectCause = cause,
lastError = null,
)
}
}
fun markConnectionFailed(message: String?) {
update {
it.copy(
adbSession = it.adbSession.copy(statusLine = "ADB 连接失败"),
disconnectCause = DisconnectCause.ConnectFailed,
lastError = message,
)
}
}
}

View File

@@ -0,0 +1,100 @@
package io.github.miuzarte.scrcpyforandroid.services
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut
import java.io.Closeable
internal class DeviceAdbAutoReconnectManager(
private val controller: ConnectionController,
private val stateStore: ConnectionStateStore,
private val backgroundRunner: DeviceAdbBackgroundRunner = DeviceAdbBackgroundRunner(),
) : Closeable {
suspend fun runKeepAliveLoop(
isForeground: () -> Boolean,
intervalMs: Long,
connectTimeoutMs: Long,
keepAliveTimeoutMs: Long,
onReconnectSuccess: suspend (host: String, port: Int) -> Unit,
onReconnectFailure: suspend (Throwable) -> Unit,
) {
backgroundRunner.runKeepAliveLoop(
sessionState = { stateStore.state.value.adbSession },
isForeground = isForeground,
intervalMs = intervalMs,
keepAliveCheck = { _, _ -> controller.keepAliveCheck(keepAliveTimeoutMs) },
reconnect = { host, port -> controller.connectWithTimeout(host, port, connectTimeoutMs) },
onReconnectSuccess = { host, port ->
controller.markKeepAliveReconnectSuccess(host, port)
onReconnectSuccess(host, port)
},
onReconnectFailure = onReconnectFailure,
)
}
suspend fun runAutoReconnectLoop(
isForeground: () -> Boolean,
isAutoReconnectEnabled: () -> Boolean,
isBusy: () -> Boolean,
isAdbConnecting: () -> Boolean,
hasActiveSession: () -> Boolean,
savedShortcuts: () -> List<DeviceShortcut>,
isBlacklisted: (String) -> Boolean,
connectTimeoutMs: Long,
probeTimeoutMs: Int,
discoverConnectService: suspend () -> Pair<String, Int>?,
onMdnsPortChanged: suspend (host: String, oldPort: Int, newPort: Int) -> Unit,
onKnownDeviceReconnected: suspend (DeviceShortcut) -> Unit,
onDiscoveredDeviceReconnected: suspend (host: String, port: Int, knownDevice: DeviceShortcut) -> Unit,
retryIntervalMs: Long,
) {
backgroundRunner.runAutoReconnectLoop(
isConnected = { stateStore.state.value.adbSession.isConnected },
isForeground = isForeground,
isAutoReconnectEnabled = {
isAutoReconnectEnabled() &&
stateStore.state.value.disconnectCause != DisconnectCause.User &&
stateStore.state.value.disconnectCause != DisconnectCause.KillAdbOnClose
},
isBusy = isBusy,
isAdbConnecting = isAdbConnecting,
hasActiveSession = hasActiveSession,
savedShortcuts = savedShortcuts,
isBlacklisted = isBlacklisted,
probeTcpReachable = { host, port ->
controller.probeTcpReachable(host, port, probeTimeoutMs)
},
discoverConnectService = discoverConnectService,
onMdnsPortChanged = onMdnsPortChanged,
connectKnownShortcut = { target ->
if (!controller.runAutoAdbConnect(target.host, target.port, connectTimeoutMs)) {
false
} else {
controller.handleAdbConnected(
host = target.host,
port = target.port,
scrcpyProfileId = target.scrcpyProfileId,
)
onKnownDeviceReconnected(target)
true
}
},
connectDiscoveredShortcut = { host, port, knownDevice ->
if (!controller.runAutoAdbConnect(host, port, connectTimeoutMs)) {
false
} else {
controller.handleAdbConnected(
host = host,
port = port,
scrcpyProfileId = knownDevice.scrcpyProfileId,
)
onDiscoveredDeviceReconnected(host, port, knownDevice)
true
}
},
retryIntervalMs = retryIntervalMs,
)
}
override fun close() {
backgroundRunner.close()
}
}

View File

@@ -27,18 +27,24 @@ internal class DeviceAdbConnectionCoordinator(
private val adbService: NativeAdbService = NativeAdbService,
) {
suspend fun connectWithTimeout(host: String, port: Int, timeoutMs: Long) {
withTimeout(timeoutMs) {
adbService.connect(host, port)
withContext(Dispatchers.IO) {
withTimeout(timeoutMs) {
adbService.connect(host, port)
}
}
}
suspend fun disconnect() {
adbService.disconnect()
withContext(Dispatchers.IO) {
adbService.disconnect()
}
}
suspend fun isConnected(timeoutMs: Long): Boolean {
return withTimeout(timeoutMs) {
adbService.isConnected()
return withContext(Dispatchers.IO) {
withTimeout(timeoutMs) {
adbService.isConnected()
}
}
}
@@ -61,24 +67,30 @@ internal class DeviceAdbConnectionCoordinator(
timeoutMs: Long = 12_000,
includeLanDevices: Boolean = true,
): Pair<String, Int>? {
return adbService.discoverPairingService(
timeoutMs = timeoutMs,
includeLanDevices = includeLanDevices,
)
return withContext(Dispatchers.IO) {
adbService.discoverPairingService(
timeoutMs = timeoutMs,
includeLanDevices = includeLanDevices,
)
}
}
suspend fun discoverConnectService(
timeoutMs: Long = 12_000,
includeLanDevices: Boolean = true,
): Pair<String, Int>? {
return adbService.discoverConnectService(
timeoutMs = timeoutMs,
includeLanDevices = includeLanDevices,
)
return withContext(Dispatchers.IO) {
adbService.discoverConnectService(
timeoutMs = timeoutMs,
includeLanDevices = includeLanDevices,
)
}
}
suspend fun pair(host: String, port: Int, pairingCode: String): Boolean {
return adbService.pair(host, port, pairingCode)
return withContext(Dispatchers.IO) {
adbService.pair(host, port, pairingCode)
}
}
suspend fun startApp(
@@ -86,10 +98,12 @@ internal class DeviceAdbConnectionCoordinator(
displayId: Int? = null,
forceStop: Boolean = false,
): String {
return adbService.startApp(
packageName = packageName,
displayId = displayId,
forceStop = forceStop,
)
return withContext(Dispatchers.IO) {
adbService.startApp(
packageName = packageName,
displayId = displayId,
forceStop = forceStop,
)
}
}
}

View File

@@ -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,

View File

@@ -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)

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -0,0 +1,580 @@
package io.github.miuzarte.scrcpyforandroid.services
import android.media.MediaCodec
import android.media.MediaCodecInfo
import android.media.MediaFormat
import android.media.MediaMuxer
import android.util.Log
import android.view.Surface
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
import java.io.File
import java.nio.ByteBuffer
import java.nio.ByteOrder
class NativeMp4Recorder(
outputFile: File,
private val includeVideo: Boolean,
private val includeAudio: Boolean,
private val inputAudioCodec: Codec?,
private val width: Int,
private val height: Int,
videoBitRate: Int,
audioBitRate: Int,
) {
private enum class TrackType { AUDIO, VIDEO }
private data class PendingSample(
val trackType: TrackType,
val data: ByteArray,
val presentationTimeUs: Long,
val flags: Int,
)
private val lock = Any()
private val muxer = MediaMuxer(
outputFile.absolutePath,
MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4,
)
private val audioBitRate = audioBitRate.takeIf { it > 0 } ?: DEFAULT_AAC_BIT_RATE
private val resolvedVideoBitRate = videoBitRate.takeIf { it > 0 }
?: (width * height * DEFAULT_VIDEO_BITS_PER_PIXEL).coerceAtLeast(MIN_VIDEO_BIT_RATE)
private val audioDecoderBufferInfo = MediaCodec.BufferInfo()
private val audioEncoderBufferInfo = MediaCodec.BufferInfo()
private val videoEncoderBufferInfo = MediaCodec.BufferInfo()
private var audioDecoder: MediaCodec? = null
private var audioEncoder: MediaCodec? = null
private var videoEncoder: MediaCodec? = null
private var videoInputSurface: Surface? = null
private var audioTrackIndex = -1
private var videoTrackIndex = -1
private var muxerStarted = false
private var released = false
private var audioBasePtsUs: Long? = null
private var videoBasePtsUs: Long? = null
private var lastAudioPtsUs = 0L
private var lastVideoPtsUs = 0L
private var audioDecoderPrepared = !includeAudio || inputAudioCodec == Codec.RAW
private var audioEncoderPrepared = false
private var audioDecoderInputEnded = false
private var audioEncoderInputEnded = false
private var audioEncoderOutputEnded = false
private var videoEncoderInputEnded = false
private var videoEncoderOutputEnded = false
private var reusablePcmBuffer = ByteArray(0)
private val pendingSamples = mutableListOf<PendingSample>()
suspend fun start() {
if (!includeVideo) return
setupVideoEncoder()
val surface = synchronized(lock) { videoInputSurface } ?: return
NativeCoreFacade.attachRecordingSurface(
surface = surface,
width = width,
height = height,
onFrameRendered = { onVideoFrameRendered() },
)
}
fun feedAudioPacket(data: ByteArray, ptsUs: Long, isConfig: Boolean) {
if (!includeAudio) return
synchronized(lock) {
if (released) return
if (isConfig) {
if (inputAudioCodec != null && inputAudioCodec != Codec.RAW) {
prepareAudioDecoder(data)
}
return
}
ensureAudioEncoder()
if (inputAudioCodec == Codec.RAW) {
queuePcmToAudioEncoder(data, ptsUs)
drainAudioEncoderLocked()
return
}
if (!audioDecoderPrepared) return
val currentDecoder = audioDecoder ?: return
val inputIndex = currentDecoder.dequeueInputBuffer(CODEC_TIMEOUT_US)
if (inputIndex < 0) {
drainAudioDecoderLocked()
drainAudioEncoderLocked()
return
}
val inputBuffer = currentDecoder.getInputBuffer(inputIndex) ?: return
inputBuffer.clear()
inputBuffer.put(data)
currentDecoder.queueInputBuffer(inputIndex, 0, data.size, ptsUs, 0)
drainAudioDecoderLocked()
drainAudioEncoderLocked()
}
}
suspend fun release() {
val surface = synchronized(lock) {
if (released) return
released = true
videoInputSurface
}
if (includeVideo && surface != null) {
NativeCoreFacade.detachRecordingSurface(surface, releaseSurface = false)
}
synchronized(lock) {
runCatching {
signalAudioEndOfStreamLocked()
signalVideoEndOfStreamLocked()
drainAudioDecoderLocked()
drainAudioEncoderLocked()
drainVideoEncoderLocked()
}.onFailure {
Log.w(TAG, "release(): final drain failed", it)
}
runCatching { audioDecoder?.stop() }
runCatching { audioDecoder?.release() }
runCatching { audioEncoder?.stop() }
runCatching { audioEncoder?.release() }
runCatching { videoEncoder?.stop() }
runCatching { videoEncoder?.release() }
runCatching { surface?.release() }
runCatching {
if (muxerStarted) {
muxer.stop()
}
}
runCatching { muxer.release() }
audioDecoder = null
audioEncoder = null
videoEncoder = null
videoInputSurface = null
}
}
private fun onVideoFrameRendered() {
synchronized(lock) {
if (released) return
drainVideoEncoderLocked()
}
}
private fun setupVideoEncoder() {
synchronized(lock) {
if (!includeVideo || videoEncoder != null || released) return
val format = MediaFormat.createVideoFormat(VIDEO_MIME, width, height).apply {
setInteger(
MediaFormat.KEY_COLOR_FORMAT,
MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface,
)
setInteger(MediaFormat.KEY_BIT_RATE, resolvedVideoBitRate)
setInteger(MediaFormat.KEY_FRAME_RATE, DEFAULT_FRAME_RATE)
setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, DEFAULT_I_FRAME_INTERVAL)
}
val codec = MediaCodec.createEncoderByType(VIDEO_MIME)
codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
val surface = codec.createInputSurface()
codec.start()
videoEncoder = codec
videoInputSurface = surface
}
}
private fun prepareAudioDecoder(config: ByteArray) {
if (audioDecoderPrepared || released) return
runCatching {
val format = when (inputAudioCodec) {
Codec.OPUS -> createOpusFormat(config)
Codec.AAC -> createAacFormat(config)
Codec.FLAC -> createFlacFormat(config)
else -> null
} ?: return
val mime = format.getString(MediaFormat.KEY_MIME) ?: return
val codec = MediaCodec.createDecoderByType(mime)
codec.configure(format, null, null, 0)
codec.start()
audioDecoder = codec
audioDecoderPrepared = true
}.onFailure {
Log.w(TAG, "prepareAudioDecoder(): codec=$inputAudioCodec failed", it)
}
}
private fun ensureAudioEncoder() {
if (!includeAudio || audioEncoderPrepared || released) return
val format = MediaFormat.createAudioFormat(
MediaFormat.MIMETYPE_AUDIO_AAC,
SAMPLE_RATE,
CHANNEL_COUNT,
).apply {
setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC)
setInteger(MediaFormat.KEY_BIT_RATE, audioBitRate)
setInteger(MediaFormat.KEY_PCM_ENCODING, PCM_ENCODING)
setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, MAX_AUDIO_ENCODER_INPUT_SIZE)
}
val codec = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC)
codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
codec.start()
audioEncoder = codec
audioEncoderPrepared = true
}
private fun drainAudioDecoderLocked() {
val currentDecoder = audioDecoder ?: return
while (true) {
when (val index = currentDecoder.dequeueOutputBuffer(audioDecoderBufferInfo, 0L)) {
MediaCodec.INFO_TRY_AGAIN_LATER -> return
MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> Unit
else -> {
if (index < 0) continue
val outputBuffer = currentDecoder.getOutputBuffer(index)
if (outputBuffer != null && audioDecoderBufferInfo.size > 0) {
ensurePcmBufferCapacity(audioDecoderBufferInfo.size)
outputBuffer.position(audioDecoderBufferInfo.offset)
outputBuffer.limit(audioDecoderBufferInfo.offset + audioDecoderBufferInfo.size)
outputBuffer.get(reusablePcmBuffer, 0, audioDecoderBufferInfo.size)
queuePcmToAudioEncoder(
reusablePcmBuffer,
audioDecoderBufferInfo.presentationTimeUs,
audioDecoderBufferInfo.size,
)
}
val eos =
audioDecoderBufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
currentDecoder.releaseOutputBuffer(index, false)
if (eos) {
audioDecoderInputEnded = true
signalAudioEncoderEndOfStreamLocked()
return
}
}
}
}
}
private fun queuePcmToAudioEncoder(
data: ByteArray,
ptsUs: Long,
sizeOverride: Int = data.size,
) {
val currentEncoder = audioEncoder ?: return
var remaining = sizeOverride
var offset = 0
while (remaining > 0) {
val inputIndex = currentEncoder.dequeueInputBuffer(CODEC_TIMEOUT_US)
if (inputIndex < 0) {
drainAudioEncoderLocked()
return
}
val inputBuffer = currentEncoder.getInputBuffer(inputIndex) ?: return
val chunkSize = minOf(inputBuffer.capacity(), remaining)
inputBuffer.clear()
inputBuffer.put(data, offset, chunkSize)
val chunkPtsUs = ptsUs + pcmBytesToDurationUs(offset)
currentEncoder.queueInputBuffer(inputIndex, 0, chunkSize, chunkPtsUs, 0)
offset += chunkSize
remaining -= chunkSize
}
}
private fun drainAudioEncoderLocked() {
val currentEncoder = audioEncoder ?: return
while (true) {
when (val index = currentEncoder.dequeueOutputBuffer(audioEncoderBufferInfo, 0L)) {
MediaCodec.INFO_TRY_AGAIN_LATER -> return
MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
registerTrackLocked(TrackType.AUDIO, currentEncoder.outputFormat)
}
else -> {
if (index < 0) continue
val outputBuffer = currentEncoder.getOutputBuffer(index)
val codecConfig =
audioEncoderBufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0
if (outputBuffer != null &&
audioEncoderBufferInfo.size > 0 &&
!codecConfig
) {
writeSampleLocked(
TrackType.AUDIO,
outputBuffer,
audioEncoderBufferInfo,
)
}
val eos =
audioEncoderBufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
currentEncoder.releaseOutputBuffer(index, false)
if (eos) {
audioEncoderOutputEnded = true
return
}
}
}
}
}
private fun drainVideoEncoderLocked() {
val currentEncoder = videoEncoder ?: return
while (true) {
when (val index = currentEncoder.dequeueOutputBuffer(videoEncoderBufferInfo, 0L)) {
MediaCodec.INFO_TRY_AGAIN_LATER -> return
MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
registerTrackLocked(TrackType.VIDEO, currentEncoder.outputFormat)
}
else -> {
if (index < 0) continue
val outputBuffer = currentEncoder.getOutputBuffer(index)
val codecConfig =
videoEncoderBufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0
if (outputBuffer != null &&
videoEncoderBufferInfo.size > 0 &&
!codecConfig
) {
writeSampleLocked(
TrackType.VIDEO,
outputBuffer,
videoEncoderBufferInfo,
)
}
val eos =
videoEncoderBufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
currentEncoder.releaseOutputBuffer(index, false)
if (eos) {
videoEncoderOutputEnded = true
return
}
}
}
}
}
private fun registerTrackLocked(trackType: TrackType, format: MediaFormat) {
when (trackType) {
TrackType.AUDIO -> {
if (audioTrackIndex >= 0) return
audioTrackIndex = muxer.addTrack(format)
}
TrackType.VIDEO -> {
if (videoTrackIndex >= 0) return
videoTrackIndex = muxer.addTrack(format)
}
}
startMuxerIfReadyLocked()
}
private fun startMuxerIfReadyLocked() {
if (muxerStarted) return
val audioReady = !includeAudio || audioTrackIndex >= 0
val videoReady = !includeVideo || videoTrackIndex >= 0
if (!audioReady || !videoReady) return
muxer.start()
muxerStarted = true
if (pendingSamples.isNotEmpty()) {
val snapshot = pendingSamples.sortedBy { it.presentationTimeUs }
pendingSamples.clear()
snapshot.forEach { sample ->
val trackIndex = when (sample.trackType) {
TrackType.AUDIO -> audioTrackIndex
TrackType.VIDEO -> videoTrackIndex
}
val normalizedPtsUs = normalizePtsLocked(
trackType = sample.trackType,
presentationTimeUs = sample.presentationTimeUs,
)
val bufferInfo = MediaCodec.BufferInfo().apply {
offset = 0
size = sample.data.size
presentationTimeUs = normalizedPtsUs
flags = sample.flags
}
muxer.writeSampleData(trackIndex, ByteBuffer.wrap(sample.data), bufferInfo)
}
}
}
private fun writeSampleLocked(
trackType: TrackType,
buffer: ByteBuffer,
bufferInfo: MediaCodec.BufferInfo,
) {
val size = bufferInfo.size
val data = ByteArray(size)
buffer.position(bufferInfo.offset)
buffer.limit(bufferInfo.offset + size)
buffer.get(data)
if (!muxerStarted) {
pendingSamples += PendingSample(
trackType = trackType,
data = data,
presentationTimeUs = bufferInfo.presentationTimeUs,
flags = bufferInfo.flags,
)
return
}
val trackIndex = when (trackType) {
TrackType.AUDIO -> audioTrackIndex
TrackType.VIDEO -> videoTrackIndex
}
val normalizedPtsUs = normalizePtsLocked(
trackType = trackType,
presentationTimeUs = bufferInfo.presentationTimeUs,
)
val writeInfo = MediaCodec.BufferInfo().apply {
offset = 0
this.size = size
presentationTimeUs = normalizedPtsUs
flags = bufferInfo.flags
}
muxer.writeSampleData(trackIndex, ByteBuffer.wrap(data), writeInfo)
}
private fun normalizePtsLocked(
trackType: TrackType,
presentationTimeUs: Long,
): Long {
return when (trackType) {
TrackType.AUDIO -> {
val base = audioBasePtsUs ?: presentationTimeUs.also {
audioBasePtsUs = it
lastAudioPtsUs = 0L
}
val normalized = (presentationTimeUs - base).coerceAtLeast(0L)
normalized.coerceAtLeast(lastAudioPtsUs).also { lastAudioPtsUs = it }
}
TrackType.VIDEO -> {
val base = videoBasePtsUs ?: presentationTimeUs.also {
videoBasePtsUs = it
lastVideoPtsUs = 0L
}
val normalized = (presentationTimeUs - base).coerceAtLeast(0L)
normalized.coerceAtLeast(lastVideoPtsUs).also { lastVideoPtsUs = it }
}
}
}
private fun signalAudioEndOfStreamLocked() {
if (!includeAudio) return
if (inputAudioCodec != Codec.RAW && !audioDecoderInputEnded) {
val currentDecoder = audioDecoder ?: return
val inputIndex = currentDecoder.dequeueInputBuffer(CODEC_TIMEOUT_US)
if (inputIndex >= 0) {
currentDecoder.queueInputBuffer(
inputIndex,
0,
0,
0L,
MediaCodec.BUFFER_FLAG_END_OF_STREAM,
)
audioDecoderInputEnded = true
}
return
}
signalAudioEncoderEndOfStreamLocked()
}
private fun signalAudioEncoderEndOfStreamLocked() {
if (audioEncoderInputEnded || audioEncoderOutputEnded) return
val currentEncoder = audioEncoder ?: return
val inputIndex = currentEncoder.dequeueInputBuffer(CODEC_TIMEOUT_US)
if (inputIndex >= 0) {
currentEncoder.queueInputBuffer(
inputIndex,
0,
0,
0L,
MediaCodec.BUFFER_FLAG_END_OF_STREAM,
)
audioEncoderInputEnded = true
}
}
private fun signalVideoEndOfStreamLocked() {
if (!includeVideo || videoEncoderInputEnded || videoEncoderOutputEnded) return
val currentEncoder = videoEncoder ?: return
currentEncoder.signalEndOfInputStream()
videoEncoderInputEnded = true
}
private fun createOpusFormat(opusHead: ByteArray): MediaFormat {
return MediaFormat.createAudioFormat(
MediaFormat.MIMETYPE_AUDIO_OPUS,
SAMPLE_RATE,
CHANNEL_COUNT,
).apply {
setByteBuffer("csd-0", ByteBuffer.wrap(opusHead))
if (opusHead.size >= 12) {
val preSkip =
((opusHead[11].toInt() and 0xFF) shl 8) or (opusHead[10].toInt() and 0xFF)
val codecDelayNs = preSkip.toLong() * 1_000_000_000L / SAMPLE_RATE
setByteBuffer("csd-1", longBuffer(codecDelayNs))
setByteBuffer("csd-2", longBuffer(OPUS_SEEK_PREROLL_NS))
}
}
}
private fun createAacFormat(config: ByteArray): MediaFormat {
return MediaFormat.createAudioFormat(
MediaFormat.MIMETYPE_AUDIO_AAC,
SAMPLE_RATE,
CHANNEL_COUNT,
).apply {
setByteBuffer("csd-0", ByteBuffer.wrap(config))
}
}
private fun createFlacFormat(config: ByteArray): MediaFormat {
return MediaFormat.createAudioFormat(
MediaFormat.MIMETYPE_AUDIO_FLAC,
SAMPLE_RATE,
CHANNEL_COUNT,
).apply {
if (config.isNotEmpty()) {
setByteBuffer("csd-0", ByteBuffer.wrap(config))
}
}
}
private fun ensurePcmBufferCapacity(requiredSize: Int) {
if (reusablePcmBuffer.size < requiredSize) {
reusablePcmBuffer = ByteArray(requiredSize)
}
}
private fun pcmBytesToDurationUs(byteCount: Int): Long {
return byteCount.toLong() * 1_000_000L / BYTES_PER_SECOND_PCM_16_STEREO
}
private fun longBuffer(value: Long): ByteBuffer {
return ByteBuffer.allocate(8).order(ByteOrder.nativeOrder()).apply {
putLong(value)
flip()
}
}
companion object {
private const val TAG = "NativeMp4Recorder"
private const val VIDEO_MIME = MediaFormat.MIMETYPE_VIDEO_AVC
private const val SAMPLE_RATE = 48_000
private const val CHANNEL_COUNT = 2
private const val PCM_ENCODING = 2
private const val CODEC_TIMEOUT_US = 10_000L
private const val OPUS_SEEK_PREROLL_NS = 80_000_000L
private const val DEFAULT_AAC_BIT_RATE = 128_000
private const val MAX_AUDIO_ENCODER_INPUT_SIZE = 16 * 1024
private const val BYTES_PER_SECOND_PCM_16_STEREO = SAMPLE_RATE * CHANNEL_COUNT * 2
private const val DEFAULT_FRAME_RATE = 60
private const val DEFAULT_I_FRAME_INTERVAL = 2
private const val DEFAULT_VIDEO_BITS_PER_PIXEL = 6
private const val MIN_VIDEO_BIT_RATE = 4_000_000
}
}

View File

@@ -0,0 +1,17 @@
package io.github.miuzarte.scrcpyforandroid.services
import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions.RecordFormat
object NativeRecordingSupport {
val supportedFormats: List<RecordFormat> = listOf(
RecordFormat.AUTO,
RecordFormat.MP4,
RecordFormat.M4A,
RecordFormat.AAC,
RecordFormat.WAV,
)
fun isSupported(format: RecordFormat): Boolean {
return format in supportedFormats
}
}

View File

@@ -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
}
}

View File

@@ -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
)
}
}

View File

@@ -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}"
}
}

View File

@@ -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
}
}

View File

@@ -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,

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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(

View File

@@ -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)
}
}
},

View File

@@ -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)
},
)
}

View File

@@ -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,

View File

@@ -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 &&