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