wip: refactoring codebase
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -32,3 +32,5 @@ google-services.json
|
|||||||
|
|
||||||
# Android Profiling
|
# Android Profiling
|
||||||
*.hprof
|
*.hprof
|
||||||
|
|
||||||
|
.kiro/
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
`keycode 120`,安卓官方([keycodes.h#349](https://android.googlesource.com/platform/frameworks/native/+/master/include/android/keycodes.h#349))的定义为
|
`keycode 120`,安卓官方([keycodes.h#349](https://android.googlesource.com/platform/frameworks/native/+/master/include/android/keycodes.h#349))的定义为
|
||||||
`System Request / Print Screen key.`,不同的厂商有不同的实现,在某些类原生(`AxionOS`) 上的行为是软重启
|
`System Request / Print Screen key.`,不同的厂商有不同的实现,在某些类原生(`AxionOS`) 上的行为是软重启
|
||||||
- I18N
|
- I18N
|
||||||
|
- More: [TODO.md](TODO.md)
|
||||||
|
|
||||||
## 建议搭配模块
|
## 建议搭配模块
|
||||||
|
|
||||||
|
|||||||
38
TODO.md
Normal file
38
TODO.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# TODO
|
||||||
|
|
||||||
|
## CURRENT
|
||||||
|
|
||||||
|
- Refactoring
|
||||||
|
|
||||||
|
## BUGS
|
||||||
|
|
||||||
|
- Figure out is virtual display destroyed at the end of scrcpy session
|
||||||
|
|
||||||
|
## WIDGETS
|
||||||
|
|
||||||
|
- `SuperTextField` Click to pop a dialog with custom notes/summary
|
||||||
|
|
||||||
|
## PARAMS
|
||||||
|
|
||||||
|
- 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).
|
||||||
|
- `-t, --show-touches` 显示受控机的物理触控 Enable "show touches" on start, restore the initial value on exit. It only shows physical touches (not clicks from scrcpy).
|
||||||
|
- `--no-power-on` 开始投屏时不唤醒屏幕 Do not power on the device on start.
|
||||||
|
- `--power-off-on-close` 结束投屏时息屏 Turn the device screen off when closing scrcpy.
|
||||||
|
- `--disable-screensaver` 投屏时禁用自动息屏 Disable screensaver while scrcpy is running.
|
||||||
|
- `--screen-off-timeout=seconds` 投屏过程中的息屏时间 Set the screen off timeout while scrcpy is running (restore the initial value on exit).
|
||||||
|
- `--require-audio` This option makes scrcpy fail if audio is enabled but does not work.
|
||||||
|
- `--no-vd-destroy-content` 投屏结束关闭虚拟显示器时不结束进程 Disable virtual display "destroy content on removal" flag. With this option, when the virtual display is closed, the running apps are moved to the main display rather than being destroyed.
|
||||||
|
- `--no-vd-system-decorations` Disable virtual display system decorations flag.
|
||||||
|
|
||||||
|
## FEATURES
|
||||||
|
|
||||||
|
- 设置项连接设备后马上启用scrcpy会话
|
||||||
|
|
||||||
|
### LOWER PRIORITY
|
||||||
|
|
||||||
|
顺序无关
|
||||||
|
|
||||||
|
- 横屏布局
|
||||||
|
- 原生悬浮窗
|
||||||
@@ -1,38 +1,31 @@
|
|||||||
package io.github.miuzarte.scrcpyforandroid
|
package io.github.miuzarte.scrcpyforandroid
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.Surface
|
import android.view.Surface
|
||||||
import androidx.core.net.toUri
|
|
||||||
import io.github.miuzarte.scrcpyforandroid.nativecore.AnnexBDecoder
|
import io.github.miuzarte.scrcpyforandroid.nativecore.AnnexBDecoder
|
||||||
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
|
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
|
||||||
import io.github.miuzarte.scrcpyforandroid.nativecore.ScrcpyAudioPlayer
|
import io.github.miuzarte.scrcpyforandroid.nativecore.ScrcpyAudioPlayer
|
||||||
import io.github.miuzarte.scrcpyforandroid.nativecore.ScrcpySessionManager
|
import io.github.miuzarte.scrcpyforandroid.nativecore.ScrcpySessionManager
|
||||||
import java.io.File
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import java.time.LocalTime
|
import kotlinx.coroutines.sync.withLock
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
import java.util.ArrayDeque
|
import java.util.ArrayDeque
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import java.util.concurrent.CopyOnWriteArraySet
|
import java.util.concurrent.CopyOnWriteArraySet
|
||||||
import java.util.concurrent.ExecutionException
|
|
||||||
import java.util.concurrent.Executors
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Facade that centralizes ADB and scrcpy native operations.
|
* Facade that centralizes video rendering and ADB operations.
|
||||||
*
|
*
|
||||||
* Provides synchronous and asynchronous helpers that run on an internal
|
* Provides helpers for:
|
||||||
* single-thread executor to serialize access to the native session manager
|
* - ADB operations (all suspend functions)
|
||||||
* and decoders. Callers should use the provided methods from UI code but
|
* - Surface/Decoder management for video rendering
|
||||||
* not perform heavy work on the main thread directly.
|
* - Control input injection (all suspend functions)
|
||||||
*/
|
*/
|
||||||
class NativeCoreFacade(private val appContext: Context) {
|
class NativeCoreFacade(private val appContext: Context) {
|
||||||
|
val sessionManager = ScrcpySessionManager(NativeAdbService(appContext))
|
||||||
private val adbService = NativeAdbService(appContext)
|
private val sessionLifecycleMutex = Mutex()
|
||||||
private val sessionManager = ScrcpySessionManager(adbService)
|
|
||||||
private val executor = Executors.newSingleThreadExecutor()
|
|
||||||
private val surfaceMap = ConcurrentHashMap<String, Surface>()
|
private val surfaceMap = ConcurrentHashMap<String, Surface>()
|
||||||
private val surfaceIdentityMap = ConcurrentHashMap<String, Int>()
|
private val surfaceIdentityMap = ConcurrentHashMap<String, Int>()
|
||||||
private val decoderMap = ConcurrentHashMap<String, AnnexBDecoder>()
|
private val decoderMap = ConcurrentHashMap<String, AnnexBDecoder>()
|
||||||
@@ -52,11 +45,10 @@ class NativeCoreFacade(private val appContext: Context) {
|
|||||||
@Volatile
|
@Volatile
|
||||||
private var currentSessionInfo: ScrcpySessionInfo? = null
|
private var currentSessionInfo: ScrcpySessionInfo? = null
|
||||||
|
|
||||||
fun close() {
|
suspend fun close() {
|
||||||
releaseAllDecoders()
|
sessionLifecycleMutex.withLock {
|
||||||
runCatching { sessionManager.stop() }
|
releaseAllDecoders()
|
||||||
runCatching { adbService.close() }
|
}
|
||||||
executor.shutdown()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,29 +61,30 @@ class NativeCoreFacade(private val appContext: Context) {
|
|||||||
* that owns the TextureView). Native decoder operations are performed synchronously
|
* that owns the TextureView). Native decoder operations are performed synchronously
|
||||||
* on the UI thread only via the decoder API; heavy work happens inside the decoder.
|
* on the UI thread only via the decoder API; heavy work happens inside the decoder.
|
||||||
*/
|
*/
|
||||||
fun registerVideoSurface(tag: String, surface: Surface) {
|
suspend fun registerVideoSurface(tag: String, surface: Surface) {
|
||||||
val newId = System.identityHashCode(surface)
|
sessionLifecycleMutex.withLock {
|
||||||
val oldId = surfaceIdentityMap[tag]
|
val newId = System.identityHashCode(surface)
|
||||||
if (oldId != null && oldId == newId && decoderMap.containsKey(tag)) {
|
val oldId = surfaceIdentityMap[tag]
|
||||||
return
|
if (oldId != null && oldId == newId && decoderMap.containsKey(tag)) {
|
||||||
}
|
|
||||||
Log.i(TAG, "registerVideoSurface(): tag=$tag surfaceId=$newId oldSurfaceId=$oldId")
|
|
||||||
surfaceMap[tag] = surface
|
|
||||||
surfaceIdentityMap[tag] = newId
|
|
||||||
val session = currentSessionInfo ?: return
|
|
||||||
ensureVideoConsumerAttached()
|
|
||||||
val decoder = decoderMap[tag]
|
|
||||||
if (decoder != null) {
|
|
||||||
val switched = decoder.switchOutputSurface(surface)
|
|
||||||
Log.i(TAG, "registerVideoSurface(): switchOutputSurface tag=$tag success=$switched")
|
|
||||||
if (switched) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
Log.i(TAG, "registerVideoSurface(): tag=$tag surfaceId=$newId oldSurfaceId=$oldId")
|
||||||
|
surfaceMap[tag] = surface
|
||||||
|
surfaceIdentityMap[tag] = newId
|
||||||
|
val session = currentSessionInfo ?: return
|
||||||
|
// ensureVideoConsumerAttached()
|
||||||
|
val decoder = decoderMap[tag]
|
||||||
|
if (decoder != null) {
|
||||||
|
val switched = decoder.switchOutputSurface(surface)
|
||||||
|
Log.i(TAG, "registerVideoSurface(): switchOutputSurface tag=$tag success=$switched")
|
||||||
|
if (switched) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
createOrReplaceDecoder(tag, surface, session)
|
||||||
}
|
}
|
||||||
createOrReplaceDecoder(tag, surface, session)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unregister the rendering Surface previously bound to `tag`.
|
* Unregister the rendering Surface previously bound to `tag`.
|
||||||
*
|
*
|
||||||
@@ -99,331 +92,22 @@ class NativeCoreFacade(private val appContext: Context) {
|
|||||||
* - When there is no active session, the decoder for the tag is released immediately.
|
* - When there is no active session, the decoder for the tag is released immediately.
|
||||||
* - This protects the native decoder from feeding into a released Surface.
|
* - This protects the native decoder from feeding into a released Surface.
|
||||||
*/
|
*/
|
||||||
fun unregisterVideoSurface(tag: String, surface: Surface? = null) {
|
suspend fun unregisterVideoSurface(tag: String, surface: Surface? = null) {
|
||||||
val currentId = surfaceIdentityMap[tag]
|
sessionLifecycleMutex.withLock {
|
||||||
val requestId = surface?.let { System.identityHashCode(it) }
|
val currentId = surfaceIdentityMap[tag]
|
||||||
if (requestId != null && currentId != null && requestId != currentId) {
|
val requestId = surface?.let { System.identityHashCode(it) }
|
||||||
Log.i(
|
if (requestId != null && currentId != null && requestId != currentId) {
|
||||||
TAG,
|
|
||||||
"unregisterVideoSurface(): skip stale request tag=$tag requestSurfaceId=$requestId currentSurfaceId=$currentId"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
Log.i(TAG, "unregisterVideoSurface(): tag=$tag surfaceId=$requestId")
|
|
||||||
surfaceMap.remove(tag)
|
|
||||||
surfaceIdentityMap.remove(tag)
|
|
||||||
if (currentSessionInfo == null) {
|
|
||||||
decoderMap.remove(tag)?.release()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pair with a device over ADB pairing protocol.
|
|
||||||
* @return true on successful pairing, false otherwise.
|
|
||||||
*/
|
|
||||||
fun adbPair(host: String, port: Int, pairingCode: String): Boolean {
|
|
||||||
return ioCall { adbService.pair(host, port, pairingCode) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Discover an ADB pairing service (mDNS) and return its host:port.
|
|
||||||
* Returns null if no service is found within `timeoutMs`.
|
|
||||||
*/
|
|
||||||
fun adbDiscoverPairingService(
|
|
||||||
timeoutMs: Long = 12_000,
|
|
||||||
includeLanDevices: Boolean = true,
|
|
||||||
): Pair<String, Int>? {
|
|
||||||
return ioCall { adbService.discoverPairingService(timeoutMs, includeLanDevices) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Discover an ADB connect service for direct connection (mDNS).
|
|
||||||
*/
|
|
||||||
fun adbDiscoverConnectService(
|
|
||||||
timeoutMs: Long = 12_000,
|
|
||||||
includeLanDevices: Boolean = true,
|
|
||||||
): Pair<String, Int>? {
|
|
||||||
return ioCall { adbService.discoverConnectService(timeoutMs, includeLanDevices) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Connect to an ADB server at the given host and port.
|
|
||||||
* Returns true on success.
|
|
||||||
*/
|
|
||||||
fun adbConnect(host: String, port: Int): Boolean = ioCall { adbService.connect(host, port) }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Disconnect current ADB connection. Always returns true.
|
|
||||||
*/
|
|
||||||
fun adbDisconnect(): Boolean {
|
|
||||||
ioCall { adbService.disconnect() }
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check whether an ADB connection is currently established.
|
|
||||||
*/
|
|
||||||
fun adbIsConnected(): Boolean = ioCall { adbService.isConnected() }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute a shell command over ADB and return its stdout as a string.
|
|
||||||
*/
|
|
||||||
fun adbShell(command: String): String = ioCall { adbService.shell(command) }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the local ADB key name used when generating or selecting key files.
|
|
||||||
*/
|
|
||||||
fun setAdbKeyName(name: String) {
|
|
||||||
adbService.keyName = name
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start a scrcpy session synchronously.
|
|
||||||
*
|
|
||||||
* - This method runs on the internal single-threaded [executor] via [ioCall], so
|
|
||||||
* callers block until the start completes. It handles server extraction, starting
|
|
||||||
* the session manager, creating decoders for any registered surfaces, and setting
|
|
||||||
* up audio playback when available.
|
|
||||||
* - After the session is established, `currentSessionInfo` is populated and
|
|
||||||
* bootstrap packets are reset so newly created decoders can be primed.
|
|
||||||
*/
|
|
||||||
fun scrcpyStart(request: ScrcpyStartRequest): ScrcpySessionInfo {
|
|
||||||
return ioCall {
|
|
||||||
Log.i(TAG, "scrcpyStart(): request codec=${request.videoCodec} audio=${request.audio}")
|
|
||||||
val serverJar = if (request.customServerUri.isNullOrBlank()) {
|
|
||||||
extractAssetToCache(request.serverAsset)
|
|
||||||
} else {
|
|
||||||
extractUriToCache(request.customServerUri.toUri())
|
|
||||||
}
|
|
||||||
|
|
||||||
val info = sessionManager.start(
|
|
||||||
serverJar.toPath(),
|
|
||||||
ScrcpySessionManager.ScrcpyStartOptions(
|
|
||||||
serverVersion = request.serverVersion,
|
|
||||||
serverRemotePath = request.serverRemotePath,
|
|
||||||
video = !request.noVideo,
|
|
||||||
audio = request.audio,
|
|
||||||
control = !request.noControl,
|
|
||||||
maxSize = request.maxSize,
|
|
||||||
maxFps = request.maxFps,
|
|
||||||
videoBitRate = request.videoBitRate,
|
|
||||||
videoCodec = request.videoCodec,
|
|
||||||
audioBitRate = request.audioBitRate,
|
|
||||||
audioCodec = request.audioCodec,
|
|
||||||
videoEncoder = request.videoEncoder,
|
|
||||||
videoCodecOptions = request.videoCodecOptions,
|
|
||||||
audioEncoder = request.audioEncoder,
|
|
||||||
audioCodecOptions = request.audioCodecOptions,
|
|
||||||
audioDup = request.audioDup,
|
|
||||||
audioSource = request.audioSource,
|
|
||||||
videoSource = request.videoSource,
|
|
||||||
cameraId = request.cameraId,
|
|
||||||
cameraFacing = request.cameraFacing,
|
|
||||||
cameraSize = request.cameraSize,
|
|
||||||
cameraAr = request.cameraAr,
|
|
||||||
cameraFps = request.cameraFps,
|
|
||||||
cameraHighSpeed = request.cameraHighSpeed,
|
|
||||||
newDisplay = request.newDisplay,
|
|
||||||
displayId = request.displayId,
|
|
||||||
crop = request.crop,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (request.turnScreenOff) {
|
|
||||||
if (request.noControl) {
|
|
||||||
Log.w(TAG, "scrcpyStart(): turnScreenOff ignored because control is disabled")
|
|
||||||
} else {
|
|
||||||
runCatching { sessionManager.setDisplayPower(on = false) }
|
|
||||||
.onFailure { e -> Log.w(TAG, "scrcpyStart(): set display power failed", e) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val session = ScrcpySessionInfo(
|
|
||||||
width = info.width,
|
|
||||||
height = info.height,
|
|
||||||
deviceName = info.deviceName,
|
|
||||||
codec = info.codecName,
|
|
||||||
controlEnabled = info.controlEnabled,
|
|
||||||
)
|
|
||||||
currentSessionInfo = session
|
|
||||||
releaseAllDecoders()
|
|
||||||
synchronized(bootstrapLock) {
|
|
||||||
bootstrapPackets.clear()
|
|
||||||
latestConfigPacket = null
|
|
||||||
}
|
|
||||||
if (!request.noVideo) {
|
|
||||||
surfaceMap.forEach { (tag, surface) ->
|
|
||||||
Log.i(TAG, "scrcpyStart(): bind decoder to tag=$tag")
|
|
||||||
createOrReplaceDecoder(tag, surface, session)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
packetCount = 0
|
|
||||||
if (!request.noVideo) {
|
|
||||||
ensureVideoConsumerAttached()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Audio player
|
|
||||||
audioPlayer?.release()
|
|
||||||
audioPlayer = null
|
|
||||||
if (info.audioCodecId != 0 && !request.noAudioPlayback) {
|
|
||||||
Log.i(
|
Log.i(
|
||||||
TAG,
|
TAG,
|
||||||
"scrcpyStart(): create audio player codecId=0x${
|
"unregisterVideoSurface(): skip stale request tag=$tag requestSurfaceId=$requestId currentSurfaceId=$currentId"
|
||||||
info.audioCodecId.toUInt().toString(16)
|
|
||||||
}"
|
|
||||||
)
|
)
|
||||||
val player = ScrcpyAudioPlayer(info.audioCodecId)
|
return
|
||||||
audioPlayer = player
|
|
||||||
sessionManager.attachAudioConsumer { packet ->
|
|
||||||
player.feedPacket(packet.data, packet.ptsUs, packet.isConfig)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Log.i(TAG, "scrcpyStart(): audio playback disabled for this session")
|
|
||||||
}
|
}
|
||||||
|
Log.i(TAG, "unregisterVideoSurface(): tag=$tag surfaceId=$requestId")
|
||||||
session
|
surfaceMap.remove(tag)
|
||||||
}
|
surfaceIdentityMap.remove(tag)
|
||||||
}
|
if (currentSessionInfo == null) {
|
||||||
|
decoderMap.remove(tag)?.release()
|
||||||
/**
|
|
||||||
* Stop any running scrcpy session and clear all video/audio consumers.
|
|
||||||
*
|
|
||||||
* - Executes on the internal executor to keep scrcpy/session-manager operations
|
|
||||||
* serialized with other IO operations.
|
|
||||||
* - Releases decoders and audio players and resets session state.
|
|
||||||
*/
|
|
||||||
fun scrcpyStop(): Boolean {
|
|
||||||
ioCall {
|
|
||||||
releaseAllDecoders()
|
|
||||||
synchronized(bootstrapLock) {
|
|
||||||
bootstrapPackets.clear()
|
|
||||||
latestConfigPacket = null
|
|
||||||
}
|
|
||||||
currentSessionInfo = null
|
|
||||||
sessionManager.clearVideoConsumer()
|
|
||||||
sessionManager.clearAudioConsumer()
|
|
||||||
sessionManager.stop()
|
|
||||||
audioPlayer?.release()
|
|
||||||
audioPlayer = null
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
fun scrcpyListEncoders(
|
|
||||||
customServerUri: String?,
|
|
||||||
remotePath: String,
|
|
||||||
serverVersion: String = "3.3.4"
|
|
||||||
): ScrcpyEncoderLists {
|
|
||||||
return ioCall {
|
|
||||||
val serverJar = if (customServerUri.isNullOrBlank()) {
|
|
||||||
extractAssetToCache(DEFAULT_SERVER_ASSET)
|
|
||||||
} else {
|
|
||||||
extractUriToCache(customServerUri.toUri())
|
|
||||||
}
|
|
||||||
val result = sessionManager.listEncoders(
|
|
||||||
serverJarPath = serverJar.toPath(),
|
|
||||||
options = ScrcpySessionManager.ScrcpyStartOptions(
|
|
||||||
serverVersion = serverVersion,
|
|
||||||
serverRemotePath = remotePath,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
ScrcpyEncoderLists(
|
|
||||||
videoEncoders = result.videoEncoders,
|
|
||||||
audioEncoders = result.audioEncoders,
|
|
||||||
videoEncoderTypes = result.videoEncoderTypes,
|
|
||||||
audioEncoderTypes = result.audioEncoderTypes,
|
|
||||||
rawOutput = result.rawOutput,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun scrcpyListCameraSizes(
|
|
||||||
customServerUri: String?,
|
|
||||||
remotePath: String,
|
|
||||||
serverVersion: String = "3.3.4"
|
|
||||||
): ScrcpyCameraSizeLists {
|
|
||||||
return ioCall {
|
|
||||||
val serverJar = if (customServerUri.isNullOrBlank()) {
|
|
||||||
extractAssetToCache(DEFAULT_SERVER_ASSET)
|
|
||||||
} else {
|
|
||||||
extractUriToCache(customServerUri.toUri())
|
|
||||||
}
|
|
||||||
val result = sessionManager.listCameraSizes(
|
|
||||||
serverJarPath = serverJar.toPath(),
|
|
||||||
options = ScrcpySessionManager.ScrcpyStartOptions(
|
|
||||||
serverVersion = serverVersion,
|
|
||||||
serverRemotePath = remotePath,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
ScrcpyCameraSizeLists(
|
|
||||||
sizes = result.sizes,
|
|
||||||
rawOutput = result.rawOutput,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun scrcpyIsStarted(): Boolean = ioCall { sessionManager.isStarted() }
|
|
||||||
|
|
||||||
fun getLastScrcpyServerCommand(): String? = ioCall { sessionManager.getLastServerCommand() }
|
|
||||||
|
|
||||||
fun scrcpyInjectKeycode(action: Int, keycode: Int, repeat: Int = 0, metaState: Int = 0) {
|
|
||||||
ioExecute {
|
|
||||||
runCatching { sessionManager.injectKeycode(action, keycode, repeat, metaState) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun scrcpyInjectText(text: String) {
|
|
||||||
ioExecute {
|
|
||||||
runCatching { sessionManager.injectText(text) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun scrcpyInjectTouch(
|
|
||||||
action: Int,
|
|
||||||
x: Int,
|
|
||||||
y: Int,
|
|
||||||
screenWidth: Int,
|
|
||||||
screenHeight: Int,
|
|
||||||
pressure: Float,
|
|
||||||
pointerId: Long = 0L,
|
|
||||||
actionButton: Int = 1,
|
|
||||||
buttons: Int = 1,
|
|
||||||
) {
|
|
||||||
ioExecute {
|
|
||||||
runCatching {
|
|
||||||
sessionManager.injectTouch(
|
|
||||||
action = action,
|
|
||||||
pointerId = pointerId,
|
|
||||||
x = x,
|
|
||||||
y = y,
|
|
||||||
screenWidth = screenWidth,
|
|
||||||
screenHeight = screenHeight,
|
|
||||||
pressure = pressure,
|
|
||||||
actionButton = actionButton,
|
|
||||||
buttons = buttons,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun scrcpyInjectScroll(
|
|
||||||
x: Int,
|
|
||||||
y: Int,
|
|
||||||
screenWidth: Int,
|
|
||||||
screenHeight: Int,
|
|
||||||
hScroll: Float,
|
|
||||||
vScroll: Float,
|
|
||||||
buttons: Int = 0,
|
|
||||||
) {
|
|
||||||
ioExecute {
|
|
||||||
runCatching {
|
|
||||||
sessionManager.injectScroll(
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
screenWidth,
|
|
||||||
screenHeight,
|
|
||||||
hScroll,
|
|
||||||
vScroll,
|
|
||||||
buttons
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -444,35 +128,73 @@ class NativeCoreFacade(private val appContext: Context) {
|
|||||||
videoFpsListeners.remove(listener)
|
videoFpsListeners.remove(listener)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun scrcpyBackOrScreenOn(action: Int = 0) {
|
suspend fun scrcpyBackOrScreenOn(action: Int = 0) {
|
||||||
ioExecute {
|
sessionManager.pressBackOrScreenOn(action)
|
||||||
runCatching { sessionManager.pressBackOrScreenOn(action) }
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called by Scrcpy.kt when a session starts.
|
||||||
|
* Sets up video decoders for registered surfaces.
|
||||||
|
*/
|
||||||
|
suspend fun onScrcpySessionStarted(
|
||||||
|
session: ScrcpySessionInfo,
|
||||||
|
sessionMgr: ScrcpySessionManager
|
||||||
|
) = sessionLifecycleMutex.withLock {
|
||||||
|
currentSessionInfo = session
|
||||||
|
releaseAllDecoders()
|
||||||
|
synchronized(bootstrapLock) {
|
||||||
|
bootstrapPackets.clear()
|
||||||
|
latestConfigPacket = null
|
||||||
|
}
|
||||||
|
|
||||||
|
surfaceMap.forEach { (tag, surface) ->
|
||||||
|
Log.i(TAG, "onScrcpySessionStarted(): bind decoder to tag=$tag")
|
||||||
|
createOrReplaceDecoder(tag, surface, session)
|
||||||
|
}
|
||||||
|
packetCount = 0
|
||||||
|
// if (!request.noVideo) {
|
||||||
|
// ensureVideoConsumerAttached()
|
||||||
|
// }
|
||||||
|
sessionMgr.attachVideoConsumer { packet ->
|
||||||
|
cacheBootstrapPacket(packet)
|
||||||
|
packetCount += 1
|
||||||
|
if (packetCount == 1L || packetCount % 120L == 0L) {
|
||||||
|
Log.i(
|
||||||
|
TAG,
|
||||||
|
"videoFeed(): packets=$packetCount key=${packet.isKeyFrame} cfg=${packet.isConfig} decoders=${decoderMap.size}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
decoderMap.forEach { (tag, decoder) ->
|
||||||
|
if (!surfaceIdentityMap.containsKey(tag)) {
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
runCatching {
|
||||||
|
decoder.feedAnnexB(
|
||||||
|
packet.data,
|
||||||
|
packet.ptsUs,
|
||||||
|
packet.isKeyFrame,
|
||||||
|
packet.isConfig
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun extractAssetToCache(assetPath: String): File {
|
/**
|
||||||
val clean = assetPath.removePrefix("/")
|
* Called by Scrcpy.kt when a session stops.
|
||||||
val source = appContext.assets.open(clean)
|
* Cleans up decoders and resets state.
|
||||||
val outputFile = File(appContext.cacheDir, File(clean).name)
|
*/
|
||||||
source.use { input ->
|
suspend fun onScrcpySessionStopped() = sessionLifecycleMutex.withLock {
|
||||||
outputFile.outputStream().use { output -> input.copyTo(output) }
|
releaseAllDecoders()
|
||||||
|
synchronized(bootstrapLock) {
|
||||||
|
bootstrapPackets.clear()
|
||||||
|
latestConfigPacket = null
|
||||||
}
|
}
|
||||||
return outputFile
|
currentSessionInfo = null
|
||||||
}
|
|
||||||
|
|
||||||
private fun extractUriToCache(uri: Uri): File {
|
|
||||||
val fileName = "custom-scrcpy-server.jar"
|
|
||||||
val outputFile = File(appContext.cacheDir, fileName)
|
|
||||||
appContext.contentResolver.openInputStream(uri).use { input ->
|
|
||||||
requireNotNull(input) { "Unable to open selected server URI" }
|
|
||||||
outputFile.outputStream().use { output -> input.copyTo(output) }
|
|
||||||
}
|
|
||||||
return outputFile
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "NativeCoreFacade"
|
private const val TAG = "NativeCoreFacade"
|
||||||
private const val DEFAULT_SERVER_ASSET = "bin/scrcpy-server-v3.3.4"
|
|
||||||
private const val MAX_BOOTSTRAP_PACKETS = 90
|
private const val MAX_BOOTSTRAP_PACKETS = 90
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
@@ -484,78 +206,6 @@ class NativeCoreFacade(private val appContext: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun defaultStartRequest(
|
|
||||||
customServerUri: String?,
|
|
||||||
maxSize: Int,
|
|
||||||
videoBitRate: Int,
|
|
||||||
remotePath: String,
|
|
||||||
videoCodec: String = "h264",
|
|
||||||
audio: Boolean = true,
|
|
||||||
audioCodec: String = "opus",
|
|
||||||
audioBitRate: Int = 128_000,
|
|
||||||
maxFps: Float = 0f,
|
|
||||||
noControl: Boolean = false,
|
|
||||||
videoEncoder: String = "",
|
|
||||||
videoCodecOptions: String = "",
|
|
||||||
audioEncoder: String = "",
|
|
||||||
audioCodecOptions: String = "",
|
|
||||||
audioDup: Boolean = false,
|
|
||||||
audioSource: String = "",
|
|
||||||
videoSource: String = "display",
|
|
||||||
cameraId: String = "",
|
|
||||||
cameraFacing: String = "",
|
|
||||||
cameraSize: String = "",
|
|
||||||
cameraAr: String = "",
|
|
||||||
cameraFps: Int = 0,
|
|
||||||
cameraHighSpeed: Boolean = false,
|
|
||||||
noAudioPlayback: Boolean = false,
|
|
||||||
requireAudio: Boolean = false,
|
|
||||||
turnScreenOff: Boolean = false,
|
|
||||||
noVideo: Boolean = false,
|
|
||||||
newDisplay: String = "",
|
|
||||||
displayId: Int? = null,
|
|
||||||
crop: String = "",
|
|
||||||
): ScrcpyStartRequest {
|
|
||||||
return ScrcpyStartRequest(
|
|
||||||
serverAsset = DEFAULT_SERVER_ASSET,
|
|
||||||
customServerUri = customServerUri,
|
|
||||||
serverVersion = "3.3.4",
|
|
||||||
serverRemotePath = remotePath,
|
|
||||||
maxSize = maxSize,
|
|
||||||
videoBitRate = videoBitRate,
|
|
||||||
videoCodec = videoCodec,
|
|
||||||
audio = audio,
|
|
||||||
audioCodec = audioCodec,
|
|
||||||
audioBitRate = audioBitRate,
|
|
||||||
maxFps = maxFps,
|
|
||||||
noControl = noControl,
|
|
||||||
videoEncoder = videoEncoder,
|
|
||||||
videoCodecOptions = videoCodecOptions,
|
|
||||||
audioEncoder = audioEncoder,
|
|
||||||
audioCodecOptions = audioCodecOptions,
|
|
||||||
audioDup = audioDup,
|
|
||||||
audioSource = audioSource,
|
|
||||||
videoSource = videoSource,
|
|
||||||
cameraId = cameraId,
|
|
||||||
cameraFacing = cameraFacing,
|
|
||||||
cameraSize = cameraSize,
|
|
||||||
cameraAr = cameraAr,
|
|
||||||
cameraFps = cameraFps,
|
|
||||||
cameraHighSpeed = cameraHighSpeed,
|
|
||||||
noAudioPlayback = noAudioPlayback,
|
|
||||||
requireAudio = requireAudio,
|
|
||||||
turnScreenOff = turnScreenOff,
|
|
||||||
noVideo = noVideo,
|
|
||||||
newDisplay = newDisplay,
|
|
||||||
displayId = displayId,
|
|
||||||
crop = crop,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun nowLogPrefix(): String {
|
|
||||||
val stamp = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"))
|
|
||||||
return "[$stamp]"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class CachedPacket(
|
private data class CachedPacket(
|
||||||
@@ -692,8 +342,9 @@ class NativeCoreFacade(private val appContext: Context) {
|
|||||||
* - The consumer iterates [decoderMap] and feeds each decoder. Errors are
|
* - The consumer iterates [decoderMap] and feeds each decoder. Errors are
|
||||||
* isolated with `runCatching` so one codec failure doesn't stop others.
|
* isolated with `runCatching` so one codec failure doesn't stop others.
|
||||||
*/
|
*/
|
||||||
private fun ensureVideoConsumerAttached() {
|
@Deprecated("TODO: Determine if this is really unnecessary")
|
||||||
sessionManager.attachVideoConsumer { packet ->
|
private suspend fun ensureVideoConsumerAttached(sessionMgr: ScrcpySessionManager) {
|
||||||
|
sessionMgr.attachVideoConsumer { packet ->
|
||||||
cacheBootstrapPacket(packet)
|
cacheBootstrapPacket(packet)
|
||||||
packetCount += 1
|
packetCount += 1
|
||||||
if (packetCount == 1L || packetCount % 120L == 0L) {
|
if (packetCount == 1L || packetCount % 120L == 0L) {
|
||||||
@@ -718,7 +369,6 @@ class NativeCoreFacade(private val appContext: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private fun releaseAllDecoders() {
|
private fun releaseAllDecoders() {
|
||||||
decoderMap.values.forEach { decoder ->
|
decoderMap.values.forEach { decoder ->
|
||||||
runCatching { decoder.release() }
|
runCatching { decoder.release() }
|
||||||
@@ -726,87 +376,11 @@ class NativeCoreFacade(private val appContext: Context) {
|
|||||||
decoderMap.clear()
|
decoderMap.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
data class ScrcpySessionInfo(
|
||||||
* Execute a blocking IO task on the internal single-thread executor and return its result.
|
val width: Int,
|
||||||
*
|
val height: Int,
|
||||||
* - This serializes access to native adb/session operations and unwraps
|
val deviceName: String,
|
||||||
* ExecutionException to rethrow the underlying cause.
|
val codec: String,
|
||||||
*/
|
val controlEnabled: Boolean,
|
||||||
private fun <T> ioCall(task: () -> T): T {
|
)
|
||||||
return try {
|
|
||||||
executor.submit<T> { task() }.get()
|
|
||||||
} catch (e: ExecutionException) {
|
|
||||||
val cause = e.cause
|
|
||||||
if (cause is Exception) {
|
|
||||||
throw cause
|
|
||||||
}
|
|
||||||
throw RuntimeException(cause ?: e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Submit a non-blocking IO task to the internal executor.
|
|
||||||
*
|
|
||||||
* - Use this for fire-and-forget native operations where the caller does not need
|
|
||||||
* a synchronous result (e.g. injecting input, toggling power, etc.).
|
|
||||||
*/
|
|
||||||
private fun ioExecute(task: () -> Unit) {
|
|
||||||
executor.execute(task)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
data class ScrcpyStartRequest(
|
|
||||||
val serverAsset: String,
|
|
||||||
val customServerUri: String?,
|
|
||||||
val serverVersion: String,
|
|
||||||
val serverRemotePath: String,
|
|
||||||
val maxSize: Int,
|
|
||||||
val videoBitRate: Int,
|
|
||||||
val videoCodec: String = "h264",
|
|
||||||
val audio: Boolean = true,
|
|
||||||
val audioCodec: String = "opus",
|
|
||||||
val audioBitRate: Int = 128_000,
|
|
||||||
val maxFps: Float = 0f,
|
|
||||||
val noControl: Boolean = false,
|
|
||||||
val videoEncoder: String = "",
|
|
||||||
val videoCodecOptions: String = "",
|
|
||||||
val audioEncoder: String = "",
|
|
||||||
val audioCodecOptions: String = "",
|
|
||||||
val audioDup: Boolean = false,
|
|
||||||
val audioSource: String = "",
|
|
||||||
val videoSource: String = "display",
|
|
||||||
val cameraId: String = "",
|
|
||||||
val cameraFacing: String = "",
|
|
||||||
val cameraSize: String = "",
|
|
||||||
val cameraAr: String = "",
|
|
||||||
val cameraFps: Int = 0,
|
|
||||||
val cameraHighSpeed: Boolean = false,
|
|
||||||
val noAudioPlayback: Boolean = false,
|
|
||||||
val requireAudio: Boolean = false,
|
|
||||||
val turnScreenOff: Boolean = false,
|
|
||||||
val noVideo: Boolean = false,
|
|
||||||
val newDisplay: String = "",
|
|
||||||
val displayId: Int? = null,
|
|
||||||
val crop: String = "",
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ScrcpyEncoderLists(
|
|
||||||
val videoEncoders: List<String>,
|
|
||||||
val audioEncoders: List<String>,
|
|
||||||
val videoEncoderTypes: Map<String, String> = emptyMap(),
|
|
||||||
val audioEncoderTypes: Map<String, String> = emptyMap(),
|
|
||||||
val rawOutput: String = "",
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ScrcpyCameraSizeLists(
|
|
||||||
val sizes: List<String>,
|
|
||||||
val rawOutput: String = "",
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ScrcpySessionInfo(
|
|
||||||
val width: Int,
|
|
||||||
val height: Int,
|
|
||||||
val deviceName: String,
|
|
||||||
val codec: String,
|
|
||||||
val controlEnabled: Boolean,
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -548,7 +548,7 @@ internal class DirectAdbConnection(
|
|||||||
* Logical ADB stream abstraction mapped to a local id. Provides blocking
|
* Logical ADB stream abstraction mapped to a local id. Provides blocking
|
||||||
* `InputStream`/`OutputStream` implementations and lifecycle helpers used by callers.
|
* `InputStream`/`OutputStream` implementations and lifecycle helpers used by callers.
|
||||||
*/
|
*/
|
||||||
internal class AdbSocketStream(
|
class AdbSocketStream(
|
||||||
val localId: Int,
|
val localId: Int,
|
||||||
private val sender: (cmd: Int, arg0: Int, arg1: Int, data: ByteArray) -> Unit,
|
private val sender: (cmd: Int, arg0: Int, arg1: Int, data: ByteArray) -> Unit,
|
||||||
) : Closeable {
|
) : Closeable {
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ internal class DirectAdbPairingClient(
|
|||||||
READY,
|
READY,
|
||||||
EXCHANGING_MSGS,
|
EXCHANGING_MSGS,
|
||||||
EXCHANGING_PEER_INFO,
|
EXCHANGING_PEER_INFO,
|
||||||
STOPPED,
|
STOPPED;
|
||||||
}
|
}
|
||||||
|
|
||||||
private lateinit var socket: Socket
|
private lateinit var socket: Socket
|
||||||
|
|||||||
@@ -2,18 +2,22 @@ package io.github.miuzarte.scrcpyforandroid.nativecore
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Higher-level ADB service that wraps `DirectAdbTransport` and provides
|
* Higher-level ADB service that wraps `DirectAdbTransport` and provides
|
||||||
* synchronized connect/disconnect/shell helpers for callers.
|
* coroutine-based connect/disconnect/shell helpers for callers.
|
||||||
*
|
*
|
||||||
* Methods are synchronized because the underlying transport is single-connection
|
* Methods use Mutex for thread-safety because the underlying transport is single-connection
|
||||||
* and accessed from the app's serialized IO executor.
|
* and may be accessed from multiple coroutines.
|
||||||
*/
|
*/
|
||||||
class NativeAdbService(appContext: Context) {
|
class NativeAdbService(appContext: Context) {
|
||||||
|
|
||||||
private val transport = DirectAdbTransport(appContext)
|
private val transport = DirectAdbTransport(appContext)
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var connection: DirectAdbConnection? = null
|
private var connection: DirectAdbConnection? = null
|
||||||
@@ -30,14 +34,13 @@ class NativeAdbService(appContext: Context) {
|
|||||||
transport.keyName = value
|
transport.keyName = value
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
suspend fun pair(host: String, port: Int, pairingCode: String): Boolean = mutex.withLock {
|
||||||
fun pair(host: String, port: Int, pairingCode: String): Boolean {
|
|
||||||
val h = host.trim()
|
val h = host.trim()
|
||||||
val code = pairingCode.trim()
|
val code = pairingCode.trim()
|
||||||
require(h.isNotBlank()) { "host is blank" }
|
require(h.isNotBlank()) { "host is blank" }
|
||||||
require(code.isNotBlank()) { "pairing code is blank" }
|
require(code.isNotBlank()) { "pairing code is blank" }
|
||||||
Log.i(TAG, "pair(): host=$h port=$port")
|
Log.i(TAG, "pair(): host=$h port=$port")
|
||||||
return try {
|
return@withLock try {
|
||||||
transport.pair(h, port, code)
|
transport.pair(h, port, code)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "pair(): failed host=$h port=$port", e)
|
Log.e(TAG, "pair(): failed host=$h port=$port", e)
|
||||||
@@ -46,12 +49,11 @@ class NativeAdbService(appContext: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
suspend fun discoverPairingService(
|
||||||
fun discoverPairingService(
|
|
||||||
timeoutMs: Long = 12_000,
|
timeoutMs: Long = 12_000,
|
||||||
includeLanDevices: Boolean = true
|
includeLanDevices: Boolean = true
|
||||||
): Pair<String, Int>? {
|
): Pair<String, Int>? = mutex.withLock {
|
||||||
return try {
|
return@withLock try {
|
||||||
transport.discoverPairingService(timeoutMs, includeLanDevices)
|
transport.discoverPairingService(timeoutMs, includeLanDevices)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "discoverPairingService(): failed", e)
|
Log.w(TAG, "discoverPairingService(): failed", e)
|
||||||
@@ -59,12 +61,11 @@ class NativeAdbService(appContext: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
suspend fun discoverConnectService(
|
||||||
fun discoverConnectService(
|
|
||||||
timeoutMs: Long = 12_000,
|
timeoutMs: Long = 12_000,
|
||||||
includeLanDevices: Boolean = true
|
includeLanDevices: Boolean = true
|
||||||
): Pair<String, Int>? {
|
): Pair<String, Int>? = mutex.withLock {
|
||||||
return try {
|
return@withLock try {
|
||||||
transport.discoverConnectService(timeoutMs, includeLanDevices)
|
transport.discoverConnectService(timeoutMs, includeLanDevices)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "discoverConnectService(): failed", e)
|
Log.w(TAG, "discoverConnectService(): failed", e)
|
||||||
@@ -77,20 +78,27 @@ class NativeAdbService(appContext: Context) {
|
|||||||
* same host:port it is reused; otherwise the previous connection is closed
|
* same host:port it is reused; otherwise the previous connection is closed
|
||||||
* before attempting the new connect.
|
* before attempting the new connect.
|
||||||
*/
|
*/
|
||||||
@Synchronized
|
suspend fun connect(
|
||||||
fun connect(host: String, port: Int): Boolean {
|
host: String,
|
||||||
|
port: Int,
|
||||||
|
timeout: Duration = Duration.INFINITE,
|
||||||
|
) = mutex.withLock {
|
||||||
Log.i(TAG, "connect(): host=$host port=$port")
|
Log.i(TAG, "connect(): host=$host port=$port")
|
||||||
val existing = connection
|
|
||||||
if (existing != null && existing.isAlive() && connectedHost == host && connectedPort == port) {
|
if (connection != null
|
||||||
return true
|
&& connection!!.isAlive()
|
||||||
|
&& connectedHost == host
|
||||||
|
&& connectedPort == port
|
||||||
|
) {
|
||||||
|
return@withLock
|
||||||
}
|
}
|
||||||
disconnect()
|
disconnectInternal()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val conn = transport.connect(host, port)
|
val conn = withTimeout(timeout) { transport.connect(host, port) }
|
||||||
connection = conn
|
connection = conn
|
||||||
connectedHost = host
|
connectedHost = host
|
||||||
connectedPort = port
|
connectedPort = port
|
||||||
return true
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "connect(): failed host=$host port=$port", e)
|
Log.e(TAG, "connect(): failed host=$host port=$port", e)
|
||||||
val detail = e.message ?: "${e.javaClass.simpleName} (no message)"
|
val detail = e.message ?: "${e.javaClass.simpleName} (no message)"
|
||||||
@@ -101,39 +109,42 @@ class NativeAdbService(appContext: Context) {
|
|||||||
/**
|
/**
|
||||||
* Close the current ADB connection immediately.
|
* Close the current ADB connection immediately.
|
||||||
*/
|
*/
|
||||||
@Synchronized
|
suspend fun disconnect() = mutex.withLock {
|
||||||
fun disconnect() {
|
disconnectInternal()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun isConnected(): Boolean = mutex.withLock {
|
||||||
|
connection?.isAlive() == true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a shell command on the connected device and return stdout text.
|
||||||
|
*/
|
||||||
|
suspend fun shell(command: String): String = mutex.withLock {
|
||||||
|
requireConnection().shell(command)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun openShellStream(command: String): AdbSocketStream = mutex.withLock {
|
||||||
|
requireConnection().openStream("shell:$command")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun push(localPath: Path, remotePath: String) = mutex.withLock {
|
||||||
|
requireConnection().push(localPath.toFile().readBytes(), remotePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun openAbstractSocket(name: String): AdbSocketStream = mutex.withLock {
|
||||||
|
requireConnection().openStream("localabstract:$name")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun close() = disconnect()
|
||||||
|
|
||||||
|
private fun disconnectInternal() {
|
||||||
runCatching { connection?.close() }
|
runCatching { connection?.close() }
|
||||||
connection = null
|
connection = null
|
||||||
connectedHost = null
|
connectedHost = null
|
||||||
connectedPort = null
|
connectedPort = null
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun isConnected(): Boolean = connection?.isAlive() == true
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute a shell command on the connected device and return stdout text.
|
|
||||||
*/
|
|
||||||
@Synchronized
|
|
||||||
fun shell(command: String): String = requireConnection().shell(command)
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
internal fun openShellStream(command: String): AdbSocketStream =
|
|
||||||
requireConnection().openStream("shell:$command")
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun push(localPath: Path, remotePath: String) {
|
|
||||||
requireConnection().push(localPath.toFile().readBytes(), remotePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
internal fun openAbstractSocket(name: String): AdbSocketStream =
|
|
||||||
requireConnection().openStream("localabstract:$name")
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun close() = disconnect()
|
|
||||||
|
|
||||||
private fun requireConnection(): DirectAdbConnection {
|
private fun requireConnection(): DirectAdbConnection {
|
||||||
return connection?.takeIf { it.isAlive() }
|
return connection?.takeIf { it.isAlive() }
|
||||||
?: throw IllegalStateException("ADB not connected")
|
?: throw IllegalStateException("ADB not connected")
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ package io.github.miuzarte.scrcpyforandroid.nativecore
|
|||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.ServerParams
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import java.io.BufferedInputStream
|
import java.io.BufferedInputStream
|
||||||
import java.io.BufferedReader
|
import java.io.BufferedReader
|
||||||
import java.io.DataInputStream
|
import java.io.DataInputStream
|
||||||
@@ -15,6 +19,8 @@ import kotlin.concurrent.thread
|
|||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
class ScrcpySessionManager(private val adbService: NativeAdbService) {
|
class ScrcpySessionManager(private val adbService: NativeAdbService) {
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var activeSession: ActiveSession? = null
|
private var activeSession: ActiveSession? = null
|
||||||
|
|
||||||
@@ -45,29 +51,27 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) {
|
|||||||
* - Initializes an [ActiveSession] which holds socket streams and reader threads.
|
* - Initializes an [ActiveSession] which holds socket streams and reader threads.
|
||||||
*
|
*
|
||||||
* Threading notes:
|
* Threading notes:
|
||||||
* - This is synchronized to avoid concurrent starts/stops.
|
* - Uses Mutex for thread-safety to avoid concurrent starts/stops.
|
||||||
* - It may block while interacting with adb; callers should execute it off the UI
|
* - It may block while interacting with adb; callers should execute it off the UI
|
||||||
* thread when appropriate. The facade uses an executor to serialize such calls.
|
* thread when appropriate.
|
||||||
*/
|
*/
|
||||||
@Synchronized
|
suspend fun start(
|
||||||
fun start(serverJarPath: Path, options: ScrcpyStartOptions): SessionInfo {
|
serverJarPath: Path,
|
||||||
stop()
|
serverCommand: String,
|
||||||
synchronized(this) {
|
scid: UInt,
|
||||||
serverLogBuffer.clear()
|
options: ClientOptions,
|
||||||
}
|
): SessionInfo = mutex.withLock {
|
||||||
val targetPath = options.serverRemotePath.ifBlank { DEFAULT_SERVER_REMOTE_PATH }
|
stopInternal()
|
||||||
val scid = random.nextInt(Int.MAX_VALUE)
|
serverLogBuffer.clear()
|
||||||
val socketName = socketNameFor(scid)
|
val socketName = socketNameFor(scid.toInt())
|
||||||
|
|
||||||
try {
|
try {
|
||||||
adbService.push(serverJarPath, targetPath)
|
lastServerCommand = serverCommand
|
||||||
val cmd = buildServerCommand(targetPath, scid, options)
|
|
||||||
lastServerCommand = cmd
|
|
||||||
Log.i(
|
Log.i(
|
||||||
TAG,
|
TAG,
|
||||||
"start(): socket=$socketName codec=${options.videoCodec} audio=${options.audio} audioCodec=${options.audioCodec}"
|
"start(): socket=$socketName codec=${options.videoCodec.string} audio=${options.audio} audioCodec=${options.audioCodec.string}"
|
||||||
)
|
)
|
||||||
val serverStream = adbService.openShellStream(cmd)
|
val serverStream = adbService.openShellStream(serverCommand)
|
||||||
val serverLogThread = startServerLogThread(serverStream, socketName)
|
val serverLogThread = startServerLogThread(serverStream, socketName)
|
||||||
Thread.sleep(SERVER_BOOT_DELAY_MS)
|
Thread.sleep(SERVER_BOOT_DELAY_MS)
|
||||||
|
|
||||||
@@ -118,7 +122,8 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val deviceName = readDeviceName(firstInput)
|
val deviceName = readDeviceName(firstInput)
|
||||||
val audioCodecId = if (options.audio) audioCodecIdFromName(options.audioCodec) else 0
|
val audioCodecId =
|
||||||
|
if (options.audio) audioCodecIdFromName(options.audioCodec.string) else 0
|
||||||
val codecId: Int
|
val codecId: Int
|
||||||
val width: Int
|
val width: Int
|
||||||
val height: Int
|
val height: Int
|
||||||
@@ -175,8 +180,7 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) {
|
|||||||
* - The reader thread stops when the session ends or the socket is closed.
|
* - The reader thread stops when the session ends or the socket is closed.
|
||||||
* - Consumers should be resilient to occasional dropped packets or reader errors.
|
* - Consumers should be resilient to occasional dropped packets or reader errors.
|
||||||
*/
|
*/
|
||||||
@Synchronized
|
suspend fun attachVideoConsumer(consumer: (VideoPacket) -> Unit): Unit = mutex.withLock {
|
||||||
fun attachVideoConsumer(consumer: (VideoPacket) -> Unit) {
|
|
||||||
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
|
||||||
@@ -224,8 +228,7 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
suspend fun clearVideoConsumer() = mutex.withLock {
|
||||||
fun clearVideoConsumer() {
|
|
||||||
videoConsumer = null
|
videoConsumer = null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,8 +240,7 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) {
|
|||||||
* - The function reads the audio stream header to determine whether audio is
|
* - The function reads the audio stream header to determine whether audio is
|
||||||
* available and exits early if disabled.
|
* available and exits early if disabled.
|
||||||
*/
|
*/
|
||||||
@Synchronized
|
suspend fun attachAudioConsumer(consumer: (AudioPacket) -> Unit): Unit = mutex.withLock {
|
||||||
fun attachAudioConsumer(consumer: (AudioPacket) -> Unit) {
|
|
||||||
val session = activeSession ?: throw IllegalStateException("scrcpy session not started")
|
val session = activeSession ?: throw IllegalStateException("scrcpy session not started")
|
||||||
val aInput = session.audioInput ?: return // audio disabled or unavailable
|
val aInput = session.audioInput ?: return // audio disabled or unavailable
|
||||||
val aStream = session.audioStream ?: return
|
val aStream = session.audioStream ?: return
|
||||||
@@ -307,8 +309,7 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
suspend fun clearAudioConsumer() = mutex.withLock {
|
||||||
fun clearAudioConsumer() {
|
|
||||||
audioConsumer = null
|
audioConsumer = null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,15 +317,14 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) {
|
|||||||
* Inject a keycode event to the control channel.
|
* Inject a keycode event to the control channel.
|
||||||
*
|
*
|
||||||
* - Requires an active control channel; throws if absent.
|
* - Requires an active control channel; throws if absent.
|
||||||
* - Synchronized to serialize control writes.
|
* - Uses Mutex to serialize control writes.
|
||||||
*/
|
*/
|
||||||
@Synchronized
|
suspend fun injectKeycode(action: Int, keycode: Int, repeat: Int = 0, metaState: Int = 0) =
|
||||||
fun injectKeycode(action: Int, keycode: Int, repeat: Int = 0, metaState: Int = 0) {
|
mutex.withLock {
|
||||||
requireControlWriter().injectKeycode(action, keycode, repeat, metaState)
|
requireControlWriter().injectKeycode(action, keycode, repeat, metaState)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
suspend fun injectText(text: String) = mutex.withLock {
|
||||||
fun injectText(text: String) {
|
|
||||||
requireControlWriter().injectText(text)
|
requireControlWriter().injectText(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,10 +333,9 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) {
|
|||||||
*
|
*
|
||||||
* - Coordinates are expected in device pixels and are written together with
|
* - Coordinates are expected in device pixels and are written together with
|
||||||
* screen dimensions so the server can interpret them correctly.
|
* screen dimensions so the server can interpret them correctly.
|
||||||
* - Synchronized to serialize control writes.
|
* - Uses Mutex to serialize control writes.
|
||||||
*/
|
*/
|
||||||
@Synchronized
|
suspend fun injectTouch(
|
||||||
fun injectTouch(
|
|
||||||
action: Int,
|
action: Int,
|
||||||
pointerId: Long,
|
pointerId: Long,
|
||||||
x: Int,
|
x: Int,
|
||||||
@@ -346,7 +345,7 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) {
|
|||||||
pressure: Float,
|
pressure: Float,
|
||||||
actionButton: Int,
|
actionButton: Int,
|
||||||
buttons: Int,
|
buttons: Int,
|
||||||
) {
|
) = mutex.withLock {
|
||||||
requireControlWriter().injectTouch(
|
requireControlWriter().injectTouch(
|
||||||
action,
|
action,
|
||||||
pointerId,
|
pointerId,
|
||||||
@@ -360,8 +359,7 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
suspend fun injectScroll(
|
||||||
fun injectScroll(
|
|
||||||
x: Int,
|
x: Int,
|
||||||
y: Int,
|
y: Int,
|
||||||
screenWidth: Int,
|
screenWidth: Int,
|
||||||
@@ -369,7 +367,7 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) {
|
|||||||
hScroll: Float,
|
hScroll: Float,
|
||||||
vScroll: Float,
|
vScroll: Float,
|
||||||
buttons: Int
|
buttons: Int
|
||||||
) {
|
) = mutex.withLock {
|
||||||
requireControlWriter().injectScroll(
|
requireControlWriter().injectScroll(
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
@@ -381,13 +379,11 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
suspend fun pressBackOrScreenOn(action: Int = KeyEvent.ACTION_DOWN) = mutex.withLock {
|
||||||
fun pressBackOrScreenOn(action: Int = KeyEvent.ACTION_DOWN) {
|
|
||||||
requireControlWriter().pressBackOrScreenOn(action)
|
requireControlWriter().pressBackOrScreenOn(action)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
suspend fun setDisplayPower(on: Boolean) = mutex.withLock {
|
||||||
fun setDisplayPower(on: Boolean) {
|
|
||||||
requireControlWriter().setDisplayPower(on)
|
requireControlWriter().setDisplayPower(on)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,8 +393,11 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) {
|
|||||||
* - Interrupts and joins reader threads with short timeouts, closes sockets,
|
* - Interrupts and joins reader threads with short timeouts, closes sockets,
|
||||||
* and clears state. It is safe to call from any thread.
|
* and clears state. It is safe to call from any thread.
|
||||||
*/
|
*/
|
||||||
@Synchronized
|
suspend fun stop() = mutex.withLock {
|
||||||
fun stop() {
|
stopInternal()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopInternal() {
|
||||||
val session = activeSession ?: return
|
val session = activeSession ?: return
|
||||||
activeSession = null
|
activeSession = null
|
||||||
videoConsumer = null
|
videoConsumer = null
|
||||||
@@ -430,20 +429,20 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) {
|
|||||||
|
|
||||||
fun getLastServerCommand(): String? = lastServerCommand
|
fun getLastServerCommand(): String? = lastServerCommand
|
||||||
|
|
||||||
@Synchronized
|
// TODO: 合并几个 --list-xxxx
|
||||||
fun listEncoders(serverJarPath: Path, options: ScrcpyStartOptions): EncoderLists {
|
suspend fun listEncoders(
|
||||||
val targetPath = options.serverRemotePath.ifBlank { DEFAULT_SERVER_REMOTE_PATH }
|
serverJarPath: Path,
|
||||||
|
serverParams: ServerParams,
|
||||||
|
serverVersion: String = "3.3.4",
|
||||||
|
serverRemotePath: String = DEFAULT_SERVER_REMOTE_PATH,
|
||||||
|
): EncoderLists = mutex.withLock {
|
||||||
|
val targetPath = serverRemotePath.ifBlank { DEFAULT_SERVER_REMOTE_PATH }
|
||||||
adbService.push(serverJarPath, targetPath)
|
adbService.push(serverJarPath, targetPath)
|
||||||
|
|
||||||
val cmd = buildServerCommand(
|
val cmd = buildServerCommand(
|
||||||
targetPath = targetPath,
|
targetPath = targetPath,
|
||||||
scid = 0x1234abcd,
|
serverParams = serverParams,
|
||||||
options = options.copy(
|
serverVersion = serverVersion,
|
||||||
video = false,
|
|
||||||
audio = false,
|
|
||||||
control = false,
|
|
||||||
listEncoders = true,
|
|
||||||
cleanup = false,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
Log.i(TAG, "listEncoders(): cmd=$cmd")
|
Log.i(TAG, "listEncoders(): cmd=$cmd")
|
||||||
// scrcpy encoder list is printed in logs, so merge stderr into stdout.
|
// scrcpy encoder list is printed in logs, so merge stderr into stdout.
|
||||||
@@ -457,20 +456,19 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) {
|
|||||||
return parsed.copy(rawOutput = output)
|
return parsed.copy(rawOutput = output)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
suspend fun listCameraSizes(
|
||||||
fun listCameraSizes(serverJarPath: Path, options: ScrcpyStartOptions): CameraSizeLists {
|
serverJarPath: Path,
|
||||||
val targetPath = options.serverRemotePath.ifBlank { DEFAULT_SERVER_REMOTE_PATH }
|
serverParams: ServerParams,
|
||||||
|
serverVersion: String = "3.3.4",
|
||||||
|
serverRemotePath: String = DEFAULT_SERVER_REMOTE_PATH,
|
||||||
|
): CameraSizeLists = mutex.withLock {
|
||||||
|
val targetPath = serverRemotePath.ifBlank { DEFAULT_SERVER_REMOTE_PATH }
|
||||||
adbService.push(serverJarPath, targetPath)
|
adbService.push(serverJarPath, targetPath)
|
||||||
|
|
||||||
val cmd = buildServerCommand(
|
val cmd = buildServerCommand(
|
||||||
targetPath = targetPath,
|
targetPath = targetPath,
|
||||||
scid = 0x2234abcd,
|
serverParams = serverParams,
|
||||||
options = options.copy(
|
serverVersion = serverVersion,
|
||||||
video = false,
|
|
||||||
audio = false,
|
|
||||||
control = false,
|
|
||||||
listCameraSizes = true,
|
|
||||||
cleanup = false,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
Log.i(TAG, "listCameraSizes(): cmd=$cmd")
|
Log.i(TAG, "listCameraSizes(): cmd=$cmd")
|
||||||
val output = adbService.shell("$cmd 2>&1")
|
val output = adbService.shell("$cmd 2>&1")
|
||||||
@@ -490,288 +488,11 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) {
|
|||||||
|
|
||||||
private fun buildServerCommand(
|
private fun buildServerCommand(
|
||||||
targetPath: String,
|
targetPath: String,
|
||||||
scid: Int,
|
serverParams: ServerParams,
|
||||||
options: ScrcpyStartOptions
|
serverVersion: String,
|
||||||
): String {
|
): String {
|
||||||
val serverArgs = buildServerArgs(scid, options)
|
val serverArgs = serverParams.build()
|
||||||
return "CLASSPATH=$targetPath app_process / com.genymobile.scrcpy.Server ${options.serverVersion} $serverArgs"
|
return "CLASSPATH=$targetPath app_process / com.genymobile.scrcpy.Server $serverVersion $serverArgs"
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildServerArgs(scid: Int, options: ScrcpyStartOptions): String {
|
|
||||||
val videoSource = options.videoSource.trim().ifBlank { "display" }
|
|
||||||
val isCameraSource = videoSource.equals("camera", ignoreCase = true)
|
|
||||||
val cameraId = options.cameraId.trim()
|
|
||||||
val hasCameraId = cameraId.isNotBlank()
|
|
||||||
val hasExplicitCameraSize = options.cameraSize.trim().isNotBlank()
|
|
||||||
val hasValidCameraFps = options.cameraFps > 0
|
|
||||||
|
|
||||||
val args = listOf(
|
|
||||||
ServerArg(
|
|
||||||
type = ServerArgType.STRING,
|
|
||||||
key = "scid",
|
|
||||||
value = String.format("%08x", scid),
|
|
||||||
),
|
|
||||||
ServerArg(
|
|
||||||
type = ServerArgType.STRING,
|
|
||||||
key = "log_level",
|
|
||||||
value = "debug",
|
|
||||||
),
|
|
||||||
ServerArg(
|
|
||||||
type = ServerArgType.BOOLEAN,
|
|
||||||
key = "tunnel_forward",
|
|
||||||
value = true,
|
|
||||||
),
|
|
||||||
ServerArg(
|
|
||||||
type = ServerArgType.BOOLEAN,
|
|
||||||
key = "cleanup",
|
|
||||||
value = options.cleanup,
|
|
||||||
),
|
|
||||||
ServerArg(
|
|
||||||
type = ServerArgType.BOOLEAN,
|
|
||||||
key = "send_device_meta",
|
|
||||||
value = true,
|
|
||||||
),
|
|
||||||
ServerArg(
|
|
||||||
type = ServerArgType.BOOLEAN,
|
|
||||||
key = "send_frame_meta",
|
|
||||||
value = true,
|
|
||||||
),
|
|
||||||
ServerArg(
|
|
||||||
type = ServerArgType.BOOLEAN,
|
|
||||||
key = "send_dummy_byte",
|
|
||||||
value = true,
|
|
||||||
),
|
|
||||||
ServerArg(
|
|
||||||
type = ServerArgType.BOOLEAN,
|
|
||||||
key = "send_codec_meta",
|
|
||||||
value = true,
|
|
||||||
),
|
|
||||||
ServerArg(
|
|
||||||
type = ServerArgType.BOOLEAN,
|
|
||||||
key = "video",
|
|
||||||
value = options.video,
|
|
||||||
),
|
|
||||||
ServerArg(
|
|
||||||
type = ServerArgType.BOOLEAN,
|
|
||||||
key = "audio",
|
|
||||||
value = options.audio,
|
|
||||||
),
|
|
||||||
ServerArg(
|
|
||||||
type = ServerArgType.BOOLEAN,
|
|
||||||
key = "control",
|
|
||||||
value = options.control,
|
|
||||||
),
|
|
||||||
ServerArg(
|
|
||||||
type = ServerArgType.STRING,
|
|
||||||
key = "video_source",
|
|
||||||
value = videoSource,
|
|
||||||
),
|
|
||||||
ServerArg(
|
|
||||||
type = ServerArgType.NUMBER,
|
|
||||||
key = "max_size",
|
|
||||||
value = options.maxSize,
|
|
||||||
),
|
|
||||||
ServerArg(
|
|
||||||
type = ServerArgType.STRING,
|
|
||||||
key = "video_codec",
|
|
||||||
value = options.videoCodec,
|
|
||||||
),
|
|
||||||
ServerArg(
|
|
||||||
type = ServerArgType.NUMBER,
|
|
||||||
key = "video_bit_rate",
|
|
||||||
value = options.videoBitRate,
|
|
||||||
),
|
|
||||||
ServerArg(
|
|
||||||
type = ServerArgType.NUMBER,
|
|
||||||
key = "max_fps",
|
|
||||||
value = options.maxFps,
|
|
||||||
),
|
|
||||||
ServerArg(
|
|
||||||
type = ServerArgType.STRING,
|
|
||||||
key = "camera_id",
|
|
||||||
value = cameraId,
|
|
||||||
includeWhen = isCameraSource,
|
|
||||||
),
|
|
||||||
ServerArg(
|
|
||||||
type = ServerArgType.STRING,
|
|
||||||
key = "camera_facing",
|
|
||||||
value = options.cameraFacing.trim(),
|
|
||||||
includeWhen = isCameraSource && !hasCameraId,
|
|
||||||
),
|
|
||||||
ServerArg(
|
|
||||||
type = ServerArgType.STRING,
|
|
||||||
key = "camera_size",
|
|
||||||
value = options.cameraSize.trim(),
|
|
||||||
includeWhen = isCameraSource,
|
|
||||||
),
|
|
||||||
ServerArg(
|
|
||||||
type = ServerArgType.STRING,
|
|
||||||
key = "camera_ar",
|
|
||||||
value = options.cameraAr.trim(),
|
|
||||||
includeWhen = isCameraSource && !hasExplicitCameraSize,
|
|
||||||
),
|
|
||||||
ServerArg(
|
|
||||||
type = ServerArgType.NUMBER,
|
|
||||||
key = "camera_fps",
|
|
||||||
value = options.cameraFps,
|
|
||||||
includeWhen = isCameraSource,
|
|
||||||
),
|
|
||||||
ServerArg(
|
|
||||||
type = ServerArgType.BOOLEAN,
|
|
||||||
key = "camera_high_speed",
|
|
||||||
value = options.cameraHighSpeed,
|
|
||||||
includeWhen = isCameraSource && options.cameraHighSpeed && hasValidCameraFps,
|
|
||||||
),
|
|
||||||
ServerArg(
|
|
||||||
type = ServerArgType.STRING,
|
|
||||||
key = "audio_codec",
|
|
||||||
value = options.audioCodec,
|
|
||||||
includeWhen = options.audio,
|
|
||||||
),
|
|
||||||
ServerArg(
|
|
||||||
type = ServerArgType.NUMBER,
|
|
||||||
key = "audio_bit_rate",
|
|
||||||
value = options.audioBitRate,
|
|
||||||
includeWhen = options.audio,
|
|
||||||
),
|
|
||||||
ServerArg(
|
|
||||||
type = ServerArgType.STRING,
|
|
||||||
key = "audio_source",
|
|
||||||
value = options.audioSource.trim(),
|
|
||||||
includeWhen = options.audio,
|
|
||||||
),
|
|
||||||
ServerArg(
|
|
||||||
type = ServerArgType.BOOLEAN,
|
|
||||||
key = "audio_dup",
|
|
||||||
value = options.audioDup,
|
|
||||||
includeWhen = options.audioDup,
|
|
||||||
),
|
|
||||||
ServerArg(
|
|
||||||
type = ServerArgType.STRING,
|
|
||||||
key = "video_encoder",
|
|
||||||
value = options.videoEncoder.trim(),
|
|
||||||
),
|
|
||||||
ServerArg(
|
|
||||||
type = ServerArgType.STRING,
|
|
||||||
key = "video_codec_options",
|
|
||||||
value = options.videoCodecOptions.trim(),
|
|
||||||
),
|
|
||||||
ServerArg(
|
|
||||||
type = ServerArgType.STRING,
|
|
||||||
key = "audio_encoder",
|
|
||||||
value = options.audioEncoder.trim(),
|
|
||||||
),
|
|
||||||
ServerArg(
|
|
||||||
type = ServerArgType.STRING,
|
|
||||||
key = "audio_codec_options",
|
|
||||||
value = options.audioCodecOptions.trim(),
|
|
||||||
),
|
|
||||||
ServerArg(
|
|
||||||
type = ServerArgType.STRING,
|
|
||||||
key = "new_display",
|
|
||||||
value = options.newDisplay.trim(),
|
|
||||||
),
|
|
||||||
ServerArg(
|
|
||||||
type = ServerArgType.NUMBER,
|
|
||||||
key = "display_id",
|
|
||||||
value = options.displayId,
|
|
||||||
),
|
|
||||||
ServerArg(
|
|
||||||
type = ServerArgType.STRING,
|
|
||||||
key = "crop",
|
|
||||||
value = options.crop.trim(),
|
|
||||||
),
|
|
||||||
ServerArg(
|
|
||||||
type = ServerArgType.BOOLEAN,
|
|
||||||
key = "list_encoders",
|
|
||||||
value = options.listEncoders,
|
|
||||||
includeWhen = options.listEncoders,
|
|
||||||
),
|
|
||||||
ServerArg(
|
|
||||||
type = ServerArgType.BOOLEAN,
|
|
||||||
key = "list_camera_sizes",
|
|
||||||
value = options.listCameraSizes,
|
|
||||||
includeWhen = options.listCameraSizes,
|
|
||||||
),
|
|
||||||
// Reserved for future args that require repeated/list values.
|
|
||||||
// ServerArg(
|
|
||||||
// type = ServerArgType.LIST,
|
|
||||||
// key = "_unused_list",
|
|
||||||
// value = emptyList<String>(),
|
|
||||||
// includeWhen = false,
|
|
||||||
// ),
|
|
||||||
)
|
|
||||||
|
|
||||||
val rendered = args.mapNotNull { it.render() }
|
|
||||||
val header = rendered.firstOrNull()
|
|
||||||
val kv = rendered.drop(1)
|
|
||||||
return if (header == null) {
|
|
||||||
kv.joinToString(" ")
|
|
||||||
} else {
|
|
||||||
"$header ${kv.joinToString(" ")}".trim()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum class ServerArgType {
|
|
||||||
NUMBER,
|
|
||||||
STRING,
|
|
||||||
BOOLEAN,
|
|
||||||
LIST,
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum class ZeroValueBehavior {
|
|
||||||
OMIT,
|
|
||||||
KEEP,
|
|
||||||
}
|
|
||||||
|
|
||||||
private data class ServerArg<T>(
|
|
||||||
val type: ServerArgType,
|
|
||||||
val key: String,
|
|
||||||
val value: T?,
|
|
||||||
val includeWhen: Boolean = true,
|
|
||||||
val zeroValueBehavior: ZeroValueBehavior = ZeroValueBehavior.OMIT,
|
|
||||||
) {
|
|
||||||
fun render(): String? {
|
|
||||||
if (!includeWhen) return null
|
|
||||||
return when (type) {
|
|
||||||
ServerArgType.STRING -> renderString(value as? String)
|
|
||||||
ServerArgType.BOOLEAN -> renderBoolean(value as? Boolean)
|
|
||||||
ServerArgType.NUMBER -> renderNumber(value as? Number)
|
|
||||||
ServerArgType.LIST -> renderList(value as? Iterable<*>)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun renderString(raw: String?): String? {
|
|
||||||
val normalized = raw?.trim().orEmpty()
|
|
||||||
if (normalized.isBlank()) return null
|
|
||||||
return "$key=$normalized"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun renderBoolean(raw: Boolean?): String? {
|
|
||||||
val normalized = raw ?: return null
|
|
||||||
return "$key=$normalized"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun renderNumber(raw: Number?): String? {
|
|
||||||
val normalized = raw ?: return null
|
|
||||||
val asDouble = normalized.toDouble()
|
|
||||||
if (asDouble == 0.0 && zeroValueBehavior == ZeroValueBehavior.OMIT) return null
|
|
||||||
val valueString = when (normalized) {
|
|
||||||
is Float -> normalized.toString()
|
|
||||||
is Double -> normalized.toString()
|
|
||||||
else -> normalized.toLong().toString()
|
|
||||||
}
|
|
||||||
return "$key=$valueString"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun renderList(raw: Iterable<*>?): String? {
|
|
||||||
val items = raw
|
|
||||||
?.mapNotNull { it?.toString()?.trim() }
|
|
||||||
?.filter { it.isNotBlank() }
|
|
||||||
.orEmpty()
|
|
||||||
if (items.isEmpty()) return null
|
|
||||||
return "$key=${items.joinToString(",")}"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseEncoderLists(output: String): EncoderLists {
|
private fun parseEncoderLists(output: String): EncoderLists {
|
||||||
@@ -836,7 +557,13 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) {
|
|||||||
).use { reader ->
|
).use { reader ->
|
||||||
while (true) {
|
while (true) {
|
||||||
val line = reader.readLine() ?: break
|
val line = reader.readLine() ?: break
|
||||||
appendServerLog(line)
|
// Use synchronized block for thread-safe log buffer access
|
||||||
|
synchronized(serverLogBuffer) {
|
||||||
|
if (serverLogBuffer.size >= SERVER_LOG_BUFFER_MAX_LINES) {
|
||||||
|
serverLogBuffer.removeFirst()
|
||||||
|
}
|
||||||
|
serverLogBuffer.addLast(line)
|
||||||
|
}
|
||||||
Log.i(TAG, "[server:$socketName] $line")
|
Log.i(TAG, "[server:$socketName] $line")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -848,21 +575,15 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
private fun appendServerLog(line: String) {
|
|
||||||
if (serverLogBuffer.size >= SERVER_LOG_BUFFER_MAX_LINES) {
|
|
||||||
serverLogBuffer.removeFirst()
|
|
||||||
}
|
|
||||||
serverLogBuffer.addLast(line)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
private fun snapshotServerLogs(maxLines: Int = 120): String {
|
private fun snapshotServerLogs(maxLines: Int = 120): String {
|
||||||
if (serverLogBuffer.isEmpty()) {
|
val snapshot = synchronized(serverLogBuffer) {
|
||||||
return ""
|
if (serverLogBuffer.isEmpty()) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
val take = maxLines.coerceIn(1, SERVER_LOG_BUFFER_MAX_LINES)
|
||||||
|
serverLogBuffer.toList().takeLast(take)
|
||||||
}
|
}
|
||||||
val take = maxLines.coerceIn(1, SERVER_LOG_BUFFER_MAX_LINES)
|
return snapshot.joinToString("\n")
|
||||||
return serverLogBuffer.toList().takeLast(take).joinToString("\n")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -871,7 +592,7 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) {
|
|||||||
* - Retries a number of times with a short delay (useful during server startup).
|
* - Retries a number of times with a short delay (useful during server startup).
|
||||||
* - Optionally expects a dummy byte on the stream to validate the server handshake.
|
* - Optionally expects a dummy byte on the stream to validate the server handshake.
|
||||||
*/
|
*/
|
||||||
private fun openAbstractSocketWithRetry(
|
private suspend fun openAbstractSocketWithRetry(
|
||||||
socketName: String,
|
socketName: String,
|
||||||
expectDummyByte: Boolean
|
expectDummyByte: Boolean
|
||||||
): AdbSocketStream {
|
): AdbSocketStream {
|
||||||
|
|||||||
@@ -19,18 +19,29 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.saveable.listSaver
|
import androidx.compose.runtime.saveable.listSaver
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||||
import androidx.compose.runtime.toMutableStateList
|
import androidx.compose.runtime.toMutableStateList
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
|
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
|
||||||
import io.github.miuzarte.scrcpyforandroid.ScrcpySessionInfo
|
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade.ScrcpySessionInfo
|
||||||
import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults
|
import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults
|
||||||
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
|
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
|
||||||
import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
|
import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
|
||||||
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.nativecore.NativeAdbService
|
||||||
import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn
|
import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.AudioSource
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.CameraFacing
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.ListOptions
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.VideoSource
|
||||||
import io.github.miuzarte.scrcpyforandroid.services.DevicePageSettings
|
import io.github.miuzarte.scrcpyforandroid.services.DevicePageSettings
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.services.EventLogger
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent
|
||||||
import io.github.miuzarte.scrcpyforandroid.services.fetchConnectedDeviceInfo
|
import io.github.miuzarte.scrcpyforandroid.services.fetchConnectedDeviceInfo
|
||||||
import io.github.miuzarte.scrcpyforandroid.services.loadDevicePageSettings
|
import io.github.miuzarte.scrcpyforandroid.services.loadDevicePageSettings
|
||||||
import io.github.miuzarte.scrcpyforandroid.services.loadQuickDevices
|
import io.github.miuzarte.scrcpyforandroid.services.loadQuickDevices
|
||||||
@@ -65,9 +76,6 @@ import top.yukonga.miuix.kmp.basic.SnackbarHostState
|
|||||||
import top.yukonga.miuix.kmp.extra.SuperBottomSheet
|
import top.yukonga.miuix.kmp.extra.SuperBottomSheet
|
||||||
import java.net.InetSocketAddress
|
import java.net.InetSocketAddress
|
||||||
import java.net.Socket
|
import java.net.Socket
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.Date
|
|
||||||
import java.util.Locale
|
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
private const val ADB_CONNECT_TIMEOUT_MS = 3_000L
|
private const val ADB_CONNECT_TIMEOUT_MS = 3_000L
|
||||||
@@ -77,10 +85,9 @@ private const val ADB_AUTO_RECONNECT_DISCOVER_TIMEOUT_MS = 1_200L
|
|||||||
private const val ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS = 1_500L
|
private const val ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS = 1_500L
|
||||||
private const val ADB_TCP_PROBE_TIMEOUT_MS = 600
|
private const val ADB_TCP_PROBE_TIMEOUT_MS = 600
|
||||||
private const val DEVICE_SHORTCUT_SEPARATOR = "\u001F"
|
private const val DEVICE_SHORTCUT_SEPARATOR = "\u001F"
|
||||||
private const val LOG_TAG = "DevicePage"
|
|
||||||
|
|
||||||
private val DeviceShortcutStateListSaver =
|
private val DeviceShortcutStateListSaver =
|
||||||
listSaver<androidx.compose.runtime.snapshots.SnapshotStateList<DeviceShortcut>, String>(
|
listSaver<SnapshotStateList<DeviceShortcut>, String>(
|
||||||
save = { list ->
|
save = { list ->
|
||||||
list.map { item ->
|
list.map { item ->
|
||||||
listOf(
|
listOf(
|
||||||
@@ -109,7 +116,7 @@ private val DeviceShortcutStateListSaver =
|
|||||||
)
|
)
|
||||||
|
|
||||||
private val StringStateListSaver =
|
private val StringStateListSaver =
|
||||||
listSaver<androidx.compose.runtime.snapshots.SnapshotStateList<String>, String>(
|
listSaver<SnapshotStateList<String>, String>(
|
||||||
save = { it.toList() },
|
save = { it.toList() },
|
||||||
restore = { it.toMutableStateList() },
|
restore = { it.toMutableStateList() },
|
||||||
)
|
)
|
||||||
@@ -118,15 +125,14 @@ private val StringStateListSaver =
|
|||||||
fun DeviceTabScreen(
|
fun DeviceTabScreen(
|
||||||
contentPadding: PaddingValues,
|
contentPadding: PaddingValues,
|
||||||
nativeCore: NativeCoreFacade,
|
nativeCore: NativeCoreFacade,
|
||||||
|
adbService: NativeAdbService,
|
||||||
|
scrcpy: Scrcpy,
|
||||||
snack: SnackbarHostState,
|
snack: SnackbarHostState,
|
||||||
scrollBehavior: ScrollBehavior,
|
scrollBehavior: ScrollBehavior,
|
||||||
virtualButtonsLayout: String,
|
virtualButtonsLayout: String,
|
||||||
showPreviewVirtualButtonText: Boolean,
|
showPreviewVirtualButtonText: Boolean,
|
||||||
previewCardHeightDp: Int,
|
previewCardHeightDp: Int,
|
||||||
themeBaseIndex: Int,
|
themeBaseIndex: Int,
|
||||||
customServerUri: String?,
|
|
||||||
serverRemotePath: String,
|
|
||||||
onServerRemotePathChange: (String) -> Unit,
|
|
||||||
videoCodec: String,
|
videoCodec: String,
|
||||||
onVideoCodecChange: (String) -> Unit,
|
onVideoCodecChange: (String) -> Unit,
|
||||||
audioEnabled: Boolean,
|
audioEnabled: Boolean,
|
||||||
@@ -287,40 +293,12 @@ fun DeviceTabScreen(
|
|||||||
currentTargetPort
|
currentTargetPort
|
||||||
) else null
|
) else null
|
||||||
|
|
||||||
val eventLog = rememberSaveable(saver = StringStateListSaver) { mutableStateListOf() }
|
|
||||||
val quickDevices =
|
val quickDevices =
|
||||||
rememberSaveable(saver = DeviceShortcutStateListSaver) { mutableStateListOf() }
|
rememberSaveable(saver = DeviceShortcutStateListSaver) { mutableStateListOf() }
|
||||||
val sessionReconnectBlacklistHosts = remember { mutableSetOf<String>() }
|
val sessionReconnectBlacklistHosts = remember { mutableSetOf<String>() }
|
||||||
|
|
||||||
LaunchedEffect(eventLog.size) {
|
LaunchedEffect(EventLogger.eventLog.size) {
|
||||||
onCanClearLogsChange(eventLog.isNotEmpty())
|
onCanClearLogsChange(EventLogger.hasLogs())
|
||||||
}
|
|
||||||
|
|
||||||
fun logEvent(message: String, level: Int = Log.INFO, error: Throwable? = null) {
|
|
||||||
val timestamp = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date())
|
|
||||||
val line = "[$timestamp] $message"
|
|
||||||
eventLog.add(0, line)
|
|
||||||
if (eventLog.size > AppDefaults.EVENT_LOG_LINES) {
|
|
||||||
eventLog.removeRange(AppDefaults.EVENT_LOG_LINES, eventLog.size)
|
|
||||||
}
|
|
||||||
when (level) {
|
|
||||||
Log.ERROR -> if (error != null) Log.e(LOG_TAG, message, error) else Log.e(
|
|
||||||
LOG_TAG,
|
|
||||||
message
|
|
||||||
)
|
|
||||||
|
|
||||||
Log.WARN -> if (error != null) Log.w(LOG_TAG, message, error) else Log.w(
|
|
||||||
LOG_TAG,
|
|
||||||
message
|
|
||||||
)
|
|
||||||
|
|
||||||
Log.DEBUG -> if (error != null) Log.d(LOG_TAG, message, error) else Log.d(
|
|
||||||
LOG_TAG,
|
|
||||||
message
|
|
||||||
)
|
|
||||||
|
|
||||||
else -> if (error != null) Log.i(LOG_TAG, message, error) else Log.i(LOG_TAG, message)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -352,8 +330,8 @@ fun DeviceTabScreen(
|
|||||||
) {
|
) {
|
||||||
withContext(adbWorkerDispatcher) {
|
withContext(adbWorkerDispatcher) {
|
||||||
// Also stops scrcpy.
|
// Also stops scrcpy.
|
||||||
runCatching { nativeCore.scrcpyStop() }
|
runCatching { scrcpy.stop() }
|
||||||
runCatching { nativeCore.adbDisconnect() }
|
runCatching { adbService.disconnect() }
|
||||||
}
|
}
|
||||||
adbConnected = false
|
adbConnected = false
|
||||||
currentTargetHost = ""
|
currentTargetHost = ""
|
||||||
@@ -383,7 +361,6 @@ fun DeviceTabScreen(
|
|||||||
if (current.host == newHost && current.port == newPort) return
|
if (current.host == newHost && current.port == newPort) return
|
||||||
|
|
||||||
sessionReconnectBlacklistHosts += current.host
|
sessionReconnectBlacklistHosts += current.host
|
||||||
logEvent("切换连接目标,先断开当前设备: ${current.host}:${current.port}")
|
|
||||||
disconnectAdbConnection(clearQuickOnlineForTarget = current)
|
disconnectAdbConnection(clearQuickOnlineForTarget = current)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,10 +397,10 @@ fun DeviceTabScreen(
|
|||||||
* - Some adb endpoints can take long to accept TCP connects; the UI should not wait
|
* - Some adb endpoints can take long to accept TCP connects; the UI should not wait
|
||||||
* 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): Boolean {
|
suspend fun connectWithTimeout(host: String, port: Int) {
|
||||||
return withContext(adbWorkerDispatcher) {
|
return withContext(adbWorkerDispatcher) {
|
||||||
withTimeout(ADB_CONNECT_TIMEOUT_MS) {
|
withTimeout(ADB_CONNECT_TIMEOUT_MS) {
|
||||||
nativeCore.adbConnect(host, port)
|
adbService.connect(host, port)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -444,12 +421,12 @@ fun DeviceTabScreen(
|
|||||||
suspend fun keepAliveCheck(host: String, port: Int): Boolean {
|
suspend fun keepAliveCheck(host: String, port: Int): Boolean {
|
||||||
return withContext(adbWorkerDispatcher) {
|
return withContext(adbWorkerDispatcher) {
|
||||||
withTimeout(ADB_KEEPALIVE_TIMEOUT_MS) {
|
withTimeout(ADB_KEEPALIVE_TIMEOUT_MS) {
|
||||||
val connected = nativeCore.adbIsConnected()
|
val connected = adbService.isConnected()
|
||||||
if (!connected) {
|
if (!connected) {
|
||||||
return@withTimeout false
|
return@withTimeout false
|
||||||
}
|
}
|
||||||
runCatching {
|
runCatching {
|
||||||
nativeCore.adbShell("echo -n 1")
|
adbService.shell("echo -n 1")
|
||||||
true
|
true
|
||||||
}.getOrElse { false }
|
}.getOrElse { false }
|
||||||
}
|
}
|
||||||
@@ -545,25 +522,21 @@ fun DeviceTabScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun runAutoAdbConnect(host: String, port: Int): Boolean {
|
suspend fun runAutoAdbConnect(host: String, port: Int) {
|
||||||
return runCatching {
|
runCatching {
|
||||||
connectWithTimeout(host, port)
|
connectWithTimeout(host, port)
|
||||||
}.getOrElse { error ->
|
}.getOrElse { error ->
|
||||||
val detail = error.message?.takeIf { it.isNotBlank() } ?: error.javaClass.simpleName
|
val detail = error.message?.takeIf { it.isNotBlank() } ?: error.javaClass.simpleName
|
||||||
logEvent("自动重连失败: $host:$port ($detail)", Log.WARN)
|
logEvent("自动重连失败: $host:$port ($detail)", Log.WARN)
|
||||||
false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refreshEncoderLists() {
|
suspend fun refreshEncoderLists() {
|
||||||
if (!adbConnected) return
|
if (!adbConnected) return
|
||||||
val remotePath = serverRemotePath.trim().ifBlank { AppDefaults.SERVER_REMOTE_PATH }
|
|
||||||
runCatching {
|
runCatching {
|
||||||
nativeCore.scrcpyListEncoders(
|
scrcpy.listOptions(list = ListOptions.ENCODERS)
|
||||||
customServerUri = customServerUri,
|
}.onSuccess { result ->
|
||||||
remotePath = remotePath,
|
val lists = result as Scrcpy.ListResult.Encoders
|
||||||
)
|
|
||||||
}.onSuccess { lists ->
|
|
||||||
onVideoEncoderOptionsChange(lists.videoEncoders)
|
onVideoEncoderOptionsChange(lists.videoEncoders)
|
||||||
onAudioEncoderOptionsChange(lists.audioEncoders)
|
onAudioEncoderOptionsChange(lists.audioEncoders)
|
||||||
onVideoEncoderTypeMapChange(lists.videoEncoderTypes)
|
onVideoEncoderTypeMapChange(lists.videoEncoderTypes)
|
||||||
@@ -574,12 +547,15 @@ fun DeviceTabScreen(
|
|||||||
if (audioEncoder.isNotBlank() && audioEncoder !in audioEncoderOptions) {
|
if (audioEncoder.isNotBlank() && audioEncoder !in audioEncoderOptions) {
|
||||||
onAudioEncoderChange("")
|
onAudioEncoderChange("")
|
||||||
}
|
}
|
||||||
logEvent("编码器列表已刷新: video=${lists.videoEncoders.size} audio=${lists.audioEncoders.size}")
|
EventLogger.logEvent("编码器列表已刷新: video=${lists.videoEncoders.size} audio=${lists.audioEncoders.size}")
|
||||||
if (lists.videoEncoders.isEmpty() && lists.audioEncoders.isEmpty()) {
|
if (lists.videoEncoders.isEmpty() && lists.audioEncoders.isEmpty()) {
|
||||||
logEvent("提示: 编码器为空,请检查 server 路径/版本与设备系统日志", Log.WARN)
|
EventLogger.logEvent(
|
||||||
|
"提示: 编码器为空,请检查 server 路径/版本与设备系统日志",
|
||||||
|
Log.WARN
|
||||||
|
)
|
||||||
val preview = lists.rawOutput.lineSequence().take(20).joinToString(" | ")
|
val preview = lists.rawOutput.lineSequence().take(20).joinToString(" | ")
|
||||||
if (preview.isNotBlank()) {
|
if (preview.isNotBlank()) {
|
||||||
logEvent("编码器原始输出: $preview", Log.DEBUG)
|
EventLogger.logEvent("编码器原始输出: $preview", Log.DEBUG)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
@@ -587,41 +563,46 @@ fun DeviceTabScreen(
|
|||||||
onAudioEncoderOptionsChange(emptyList())
|
onAudioEncoderOptionsChange(emptyList())
|
||||||
onVideoEncoderTypeMapChange(emptyMap())
|
onVideoEncoderTypeMapChange(emptyMap())
|
||||||
onAudioEncoderTypeMapChange(emptyMap())
|
onAudioEncoderTypeMapChange(emptyMap())
|
||||||
logEvent("读取编码器列表失败: ${e.message ?: e.javaClass.simpleName}", Log.ERROR, e)
|
EventLogger.logEvent(
|
||||||
|
"读取编码器列表失败: ${e.message ?: e.javaClass.simpleName}",
|
||||||
|
Log.ERROR,
|
||||||
|
e
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refreshCameraSizeLists() {
|
suspend fun refreshCameraSizeLists() {
|
||||||
if (!adbConnected) return
|
if (!adbConnected) return
|
||||||
val remotePath = serverRemotePath.trim().ifBlank { AppDefaults.SERVER_REMOTE_PATH }
|
|
||||||
runCatching {
|
runCatching {
|
||||||
nativeCore.scrcpyListCameraSizes(
|
scrcpy.listOptions(ListOptions.CAMERA_SIZES)
|
||||||
customServerUri = customServerUri,
|
}.onSuccess { result ->
|
||||||
remotePath = remotePath,
|
val lists = result as Scrcpy.ListResult.CameraSizes
|
||||||
)
|
|
||||||
}.onSuccess { lists ->
|
|
||||||
onCameraSizeOptionsChange(lists.sizes)
|
onCameraSizeOptionsChange(lists.sizes)
|
||||||
if (cameraSizePreset.isNotBlank() && cameraSizePreset != "custom" && cameraSizePreset !in lists.sizes) {
|
if (cameraSizePreset.isNotBlank() && cameraSizePreset != "custom" && cameraSizePreset !in lists.sizes) {
|
||||||
onCameraSizePresetChange("")
|
onCameraSizePresetChange("")
|
||||||
}
|
}
|
||||||
logEvent("camera sizes 已刷新: count=${lists.sizes.size}")
|
EventLogger.logEvent("camera sizes 已刷新: count=${lists.sizes.size}")
|
||||||
if (lists.sizes.isEmpty()) {
|
if (lists.sizes.isEmpty()) {
|
||||||
val preview = lists.rawOutput.lineSequence().take(20).joinToString(" | ")
|
val preview = lists.rawOutput.lineSequence().take(20).joinToString(" | ")
|
||||||
if (preview.isNotBlank()) {
|
if (preview.isNotBlank()) {
|
||||||
logEvent("camera sizes 原始输出: $preview", Log.DEBUG)
|
EventLogger.logEvent("camera sizes 原始输出: $preview", Log.DEBUG)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
onCameraSizeOptionsChange(emptyList())
|
onCameraSizeOptionsChange(emptyList())
|
||||||
logEvent("读取 camera sizes 失败: ${e.message ?: e.javaClass.simpleName}", Log.ERROR, e)
|
EventLogger.logEvent(
|
||||||
|
"读取 camera sizes 失败: ${e.message ?: e.javaClass.simpleName}",
|
||||||
|
Log.ERROR,
|
||||||
|
e
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun handleAdbConnected(host: String, port: Int) {
|
suspend fun handleAdbConnected(host: String, port: Int) {
|
||||||
currentTargetHost = host
|
currentTargetHost = host
|
||||||
currentTargetPort = port
|
currentTargetPort = port
|
||||||
|
|
||||||
val info = fetchConnectedDeviceInfo(nativeCore, host, port)
|
val info = fetchConnectedDeviceInfo(adbService, host, port)
|
||||||
val fullLabel = if (info.serial.isNotBlank()) {
|
val fullLabel = if (info.serial.isNotBlank()) {
|
||||||
"${info.model} (${info.serial})"
|
"${info.model} (${info.serial})"
|
||||||
} else {
|
} else {
|
||||||
@@ -761,18 +742,18 @@ fun DeviceTabScreen(
|
|||||||
if (alive) continue
|
if (alive) continue
|
||||||
|
|
||||||
logEvent("ADB 长连接中断,尝试自动重连: $host:$port", Log.WARN)
|
logEvent("ADB 长连接中断,尝试自动重连: $host:$port", Log.WARN)
|
||||||
val reconnected = runCatching { connectWithTimeout(host, port) }.getOrElse { false }
|
try {
|
||||||
adbConnected = reconnected
|
connectWithTimeout(host, port)
|
||||||
if (reconnected) {
|
adbConnected = true
|
||||||
statusLine = "$host:$port"
|
statusLine = "$host:$port"
|
||||||
logEvent("ADB 自动重连成功: $host:$port")
|
logEvent("ADB 自动重连成功: $host:$port")
|
||||||
scope.launch {
|
scope.launch {
|
||||||
snack.showSnackbar("ADB 自动重连成功")
|
snack.showSnackbar("ADB 自动重连成功")
|
||||||
}
|
}
|
||||||
} else {
|
} catch (e: Exception) {
|
||||||
disconnectAdbConnection()
|
disconnectAdbConnection()
|
||||||
statusLine = "ADB 连接断开"
|
statusLine = "ADB 连接断开"
|
||||||
logEvent("ADB 自动重连失败: $host:$port", Log.ERROR)
|
logEvent("ADB 自动重连失败: $e", Log.ERROR)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
snack.showSnackbar("ADB 自动重连失败")
|
snack.showSnackbar("ADB 自动重连失败")
|
||||||
}
|
}
|
||||||
@@ -806,26 +787,27 @@ fun DeviceTabScreen(
|
|||||||
if (!portReachable) continue
|
if (!portReachable) continue
|
||||||
|
|
||||||
quickConnectTriedOnce += targetKey
|
quickConnectTriedOnce += targetKey
|
||||||
val ok = runAutoAdbConnect(target.host, target.port)
|
try {
|
||||||
adbConnected = ok
|
runAutoAdbConnect(target.host, target.port)
|
||||||
upsertQuickDevice(
|
adbConnected = true
|
||||||
context,
|
upsertQuickDevice(
|
||||||
quickDevices,
|
context,
|
||||||
target.host,
|
quickDevices,
|
||||||
target.port,
|
target.host,
|
||||||
ok
|
target.port,
|
||||||
)
|
true,
|
||||||
if (ok) {
|
)
|
||||||
handleAdbConnected(target.host, target.port)
|
handleAdbConnected(target.host, target.port)
|
||||||
logEvent("ADB 快速探测连接成功: ${target.host}:${target.port}")
|
logEvent("ADB 快速探测连接成功: ${target.host}:${target.port}")
|
||||||
break
|
} catch (_: Exception) {
|
||||||
}
|
}
|
||||||
|
break
|
||||||
}
|
}
|
||||||
if (adbConnected) break
|
if (adbConnected) break
|
||||||
}
|
}
|
||||||
|
|
||||||
val discovered = withContext(Dispatchers.IO) {
|
val discovered = withContext(Dispatchers.IO) {
|
||||||
nativeCore.adbDiscoverConnectService(
|
adbService.discoverConnectService(
|
||||||
timeoutMs = ADB_AUTO_RECONNECT_DISCOVER_TIMEOUT_MS,
|
timeoutMs = ADB_AUTO_RECONNECT_DISCOVER_TIMEOUT_MS,
|
||||||
includeLanDevices = adbMdnsLanDiscoveryEnabled,
|
includeLanDevices = adbMdnsLanDiscoveryEnabled,
|
||||||
)
|
)
|
||||||
@@ -870,20 +852,19 @@ fun DeviceTabScreen(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
val ok = runAutoAdbConnect(discoveredHost, discoveredPort)
|
try {
|
||||||
adbConnected = ok
|
runAutoAdbConnect(discoveredHost, discoveredPort)
|
||||||
upsertQuickDevice(
|
adbConnected = true
|
||||||
context,
|
upsertQuickDevice(
|
||||||
quickDevices,
|
context,
|
||||||
discoveredHost,
|
quickDevices,
|
||||||
discoveredPort,
|
discoveredHost,
|
||||||
ok
|
discoveredPort,
|
||||||
)
|
true
|
||||||
if (ok) {
|
)
|
||||||
handleAdbConnected(discoveredHost, discoveredPort)
|
handleAdbConnected(discoveredHost, discoveredPort)
|
||||||
logEvent("ADB 自动重连成功: $discoveredHost:$discoveredPort")
|
logEvent("ADB 自动重连成功: $discoveredHost:$discoveredPort")
|
||||||
} else {
|
} catch (_: Exception) {
|
||||||
logEvent("ADB 自动重连失败: $discoveredHost:$discoveredPort", Log.WARN)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
delay(ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS)
|
delay(ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS)
|
||||||
@@ -925,7 +906,7 @@ fun DeviceTabScreen(
|
|||||||
runBusy("刷新 Camera Sizes") { refreshCameraSizeLists() }
|
runBusy("刷新 Camera Sizes") { refreshCameraSizeLists() }
|
||||||
}
|
}
|
||||||
onClearLogsActionChange {
|
onClearLogsActionChange {
|
||||||
eventLog.clear()
|
EventLogger.clearLogs()
|
||||||
}
|
}
|
||||||
onOpenReorderDevicesActionChange {
|
onOpenReorderDevicesActionChange {
|
||||||
showReorderSheet = true
|
showReorderSheet = true
|
||||||
@@ -973,8 +954,8 @@ fun DeviceTabScreen(
|
|||||||
fun sendVirtualButtonAction(action: VirtualButtonAction) {
|
fun sendVirtualButtonAction(action: VirtualButtonAction) {
|
||||||
val keycode = action.keycode ?: return
|
val keycode = action.keycode ?: return
|
||||||
runBusy("发送 ${action.title}") {
|
runBusy("发送 ${action.title}") {
|
||||||
nativeCore.scrcpyInjectKeycode(0, keycode)
|
nativeCore.sessionManager.injectKeycode(0, keycode)
|
||||||
nativeCore.scrcpyInjectKeycode(1, keycode)
|
nativeCore.sessionManager.injectKeycode(1, keycode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1024,12 +1005,13 @@ fun DeviceTabScreen(
|
|||||||
itemsIndexed(quickDevices, key = { _, device -> device.id }) { _, device ->
|
itemsIndexed(quickDevices, key = { _, device -> device.id }) { _, device ->
|
||||||
val host = device.host
|
val host = device.host
|
||||||
val port = device.port
|
val port = device.port
|
||||||
val isConnectedTarget =
|
val isConnectedTarget = adbConnected
|
||||||
adbConnected && currentTarget?.host == host && currentTarget.port == port
|
&& currentTarget?.host == host
|
||||||
|
&& currentTarget.port == port
|
||||||
|
|
||||||
DeviceTile(
|
DeviceTile(
|
||||||
device = device,
|
device = device,
|
||||||
actionText = if (isConnectedTarget) "断开" else "连接",
|
actionText = if (!isConnectedTarget) "连接" else "断开",
|
||||||
actionEnabled = !busy && !adbConnecting,
|
actionEnabled = !busy && !adbConnecting,
|
||||||
actionInProgress = adbConnecting && activeDeviceActionId == device.id,
|
actionInProgress = adbConnecting && activeDeviceActionId == device.id,
|
||||||
onLongPress = { editingDeviceId = device.id },
|
onLongPress = { editingDeviceId = device.id },
|
||||||
@@ -1040,7 +1022,24 @@ fun DeviceTabScreen(
|
|||||||
},
|
},
|
||||||
onAction = {
|
onAction = {
|
||||||
haptics.contextClick()
|
haptics.contextClick()
|
||||||
if (isConnectedTarget) {
|
if (!isConnectedTarget) {
|
||||||
|
activeDeviceActionId = device.id
|
||||||
|
runAdbConnect("连接 ADB", onFinished = { activeDeviceActionId = null }) {
|
||||||
|
disconnectCurrentTargetBeforeConnecting(host, port)
|
||||||
|
try {
|
||||||
|
connectWithTimeout(host, port)
|
||||||
|
adbConnected = true
|
||||||
|
upsertQuickDevice(context, quickDevices, host, port, true)
|
||||||
|
handleAdbConnected(host, port)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
statusLine = "ADB 连接失败"
|
||||||
|
logEvent("ADB 连接失败: $e", Log.ERROR)
|
||||||
|
scope.launch {
|
||||||
|
snack.showSnackbar("ADB 连接失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
activeDeviceActionId = device.id
|
activeDeviceActionId = device.id
|
||||||
runAdbConnect("断开 ADB", onFinished = { activeDeviceActionId = null }) {
|
runAdbConnect("断开 ADB", onFinished = { activeDeviceActionId = null }) {
|
||||||
sessionReconnectBlacklistHosts += host
|
sessionReconnectBlacklistHosts += host
|
||||||
@@ -1050,23 +1049,6 @@ fun DeviceTabScreen(
|
|||||||
showSnackMessage = "ADB 已断开",
|
showSnackMessage = "ADB 已断开",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
activeDeviceActionId = device.id
|
|
||||||
runAdbConnect("连接 ADB", onFinished = { activeDeviceActionId = null }) {
|
|
||||||
disconnectCurrentTargetBeforeConnecting(host, port)
|
|
||||||
val ok = connectWithTimeout(host, port)
|
|
||||||
adbConnected = ok
|
|
||||||
upsertQuickDevice(context, quickDevices, host, port, ok)
|
|
||||||
if (ok) {
|
|
||||||
handleAdbConnected(host, port)
|
|
||||||
} else {
|
|
||||||
statusLine = "ADB 连接失败"
|
|
||||||
logEvent("ADB 连接失败: $host:$port", Log.ERROR)
|
|
||||||
scope.launch {
|
|
||||||
snack.showSnackbar("ADB 连接失败")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -1093,16 +1075,16 @@ fun DeviceTabScreen(
|
|||||||
},
|
},
|
||||||
onConnect = {
|
onConnect = {
|
||||||
val target = parseQuickTarget(quickConnectInput) ?: return@QuickConnectCard
|
val target = parseQuickTarget(quickConnectInput) ?: return@QuickConnectCard
|
||||||
runAdbConnect("连接 ADB") {
|
runAdbConnect("连接 ADB", onFinished = { activeDeviceActionId = null }) {
|
||||||
disconnectCurrentTargetBeforeConnecting(target.host, target.port)
|
disconnectCurrentTargetBeforeConnecting(target.host, target.port)
|
||||||
val ok = connectWithTimeout(target.host, target.port)
|
try {
|
||||||
adbConnected = ok
|
connectWithTimeout(target.host, target.port)
|
||||||
upsertQuickDevice(context, quickDevices, target.host, target.port, ok)
|
adbConnected = true
|
||||||
if (ok) {
|
upsertQuickDevice(context, quickDevices, target.host, target.port, true)
|
||||||
handleAdbConnected(target.host, target.port)
|
handleAdbConnected(target.host, target.port)
|
||||||
} else {
|
} catch (e: Exception) {
|
||||||
statusLine = "ADB 连接失败"
|
statusLine = "ADB 连接失败"
|
||||||
logEvent("ADB 连接失败: ${target.host}:${target.port}", Log.ERROR)
|
logEvent("ADB 连接失败: $e", Log.ERROR)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
snack.showSnackbar("ADB 连接失败")
|
snack.showSnackbar("ADB 连接失败")
|
||||||
}
|
}
|
||||||
@@ -1116,7 +1098,7 @@ fun DeviceTabScreen(
|
|||||||
busy = busy,
|
busy = busy,
|
||||||
autoDiscoverOnDialogOpen = adbPairingAutoDiscoverOnDialogOpen,
|
autoDiscoverOnDialogOpen = adbPairingAutoDiscoverOnDialogOpen,
|
||||||
onDiscoverTarget = {
|
onDiscoverTarget = {
|
||||||
nativeCore.adbDiscoverPairingService(
|
adbService.discoverPairingService(
|
||||||
includeLanDevices = adbMdnsLanDiscoveryEnabled,
|
includeLanDevices = adbMdnsLanDiscoveryEnabled,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -1125,7 +1107,7 @@ fun DeviceTabScreen(
|
|||||||
val resolvedHost = host.trim()
|
val resolvedHost = host.trim()
|
||||||
val resolvedPort = port.toIntOrNull() ?: return@runBusy
|
val resolvedPort = port.toIntOrNull() ?: return@runBusy
|
||||||
val resolvedCode = code.trim()
|
val resolvedCode = code.trim()
|
||||||
val ok = nativeCore.adbPair(
|
val ok = adbService.pair(
|
||||||
resolvedHost,
|
resolvedHost,
|
||||||
resolvedPort,
|
resolvedPort,
|
||||||
resolvedCode,
|
resolvedCode,
|
||||||
@@ -1222,52 +1204,55 @@ fun DeviceTabScreen(
|
|||||||
cropY.filter(Char::isDigit),
|
cropY.filter(Char::isDigit),
|
||||||
)
|
)
|
||||||
val effectiveTurnScreenOff = turnScreenOff && !noControl
|
val effectiveTurnScreenOff = turnScreenOff && !noControl
|
||||||
val session = nativeCore.scrcpyStart(
|
|
||||||
NativeCoreFacade.defaultStartRequest(
|
val options = ClientOptions(
|
||||||
customServerUri = customServerUri,
|
crop = crop,
|
||||||
maxSize = maxSize,
|
videoCodecOptions = videoCodecOptions,
|
||||||
maxFps = maxFps,
|
audioCodecOptions = audioCodecOptions,
|
||||||
videoBitRate = bitRateBps,
|
videoEncoder = videoEncoder,
|
||||||
remotePath = serverRemotePath.trim(),
|
audioEncoder = audioEncoder,
|
||||||
videoCodec = videoCodec,
|
cameraId = resolvedCameraId,
|
||||||
audio = audioEnabled,
|
cameraSize = resolvedCameraSize,
|
||||||
audioCodec = audioCodec,
|
cameraAr = resolvedCameraAr,
|
||||||
audioBitRate = audioBitRateBps,
|
cameraFps = resolvedCameraFps.toUShort(),
|
||||||
noControl = noControl,
|
videoCodec = Codec.fromString(videoCodec),
|
||||||
videoEncoder = videoEncoder,
|
audioCodec = Codec.fromString(audioCodec),
|
||||||
videoCodecOptions = videoCodecOptions,
|
videoSource = if (resolvedVideoSource == "camera") VideoSource.CAMERA else VideoSource.DISPLAY,
|
||||||
audioEncoder = audioEncoder,
|
audioSource = if (resolvedAudioSource.isNotBlank()) AudioSource.fromString(
|
||||||
audioCodecOptions = audioCodecOptions,
|
resolvedAudioSource
|
||||||
audioDup = audioDup,
|
) else AudioSource.AUTO,
|
||||||
audioSource = resolvedAudioSource,
|
cameraFacing = if (resolvedCameraFacing.isNotBlank()) CameraFacing.fromString(
|
||||||
videoSource = resolvedVideoSource,
|
resolvedCameraFacing
|
||||||
cameraId = resolvedCameraId,
|
) else CameraFacing.ANY,
|
||||||
cameraFacing = resolvedCameraFacing,
|
maxSize = maxSize.toUShort(),
|
||||||
cameraSize = resolvedCameraSize,
|
videoBitRate = bitRateBps.toUInt(),
|
||||||
cameraAr = resolvedCameraAr,
|
audioBitRate = audioBitRateBps.toUInt(),
|
||||||
cameraFps = resolvedCameraFps,
|
maxFps = if (maxFps > 0f) maxFps.toString() else "",
|
||||||
cameraHighSpeed = cameraHighSpeed,
|
displayId = (displayId ?: 0).toUInt(),
|
||||||
noAudioPlayback = noAudioPlayback,
|
control = !noControl,
|
||||||
noVideo = noVideo,
|
video = !noVideo,
|
||||||
requireAudio = requireAudio,
|
audio = audioEnabled,
|
||||||
turnScreenOff = effectiveTurnScreenOff,
|
requireAudio = requireAudio,
|
||||||
newDisplay = newDisplayArg,
|
audioPlayback = !noAudioPlayback,
|
||||||
displayId = displayId,
|
turnScreenOff = effectiveTurnScreenOff,
|
||||||
crop = crop,
|
audioDup = audioDup,
|
||||||
),
|
newDisplay = newDisplayArg,
|
||||||
|
cameraHighSpeed = cameraHighSpeed,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Start scrcpy using Scrcpy class
|
||||||
|
val session = scrcpy.start(
|
||||||
|
options = options,
|
||||||
|
)
|
||||||
|
|
||||||
sessionInfo = session
|
sessionInfo = session
|
||||||
statusLine = "scrcpy 运行中"
|
statusLine = "scrcpy 运行中"
|
||||||
@SuppressLint("DefaultLocale")
|
@SuppressLint("DefaultLocale")
|
||||||
val videoDetail = if (noVideo) {
|
val videoDetail = if (noVideo) {
|
||||||
"off"
|
"off"
|
||||||
} else {
|
} else {
|
||||||
"${session.codec} ${session.width}x${session.height} @${
|
"${session.codec} ${session.width}x${session.height} " +
|
||||||
String.format(
|
"@${String.format("%.1f", bitRateMbps)}Mbps"
|
||||||
"%.1f",
|
|
||||||
bitRateMbps
|
|
||||||
)
|
|
||||||
}Mbps"
|
|
||||||
}
|
}
|
||||||
val audioDetail = if (!audioEnabled) {
|
val audioDetail = if (!audioEnabled) {
|
||||||
"off"
|
"off"
|
||||||
@@ -1279,17 +1264,16 @@ fun DeviceTabScreen(
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
snack.showSnackbar("scrcpy 已启动")
|
snack.showSnackbar("scrcpy 已启动")
|
||||||
}
|
}
|
||||||
nativeCore.getLastScrcpyServerCommand()?.let { command ->
|
scrcpy.getLastServerCommand()?.let { command ->
|
||||||
logEvent("scrcpy-server args: $command")
|
logEvent("scrcpy-server args: $command")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onStop = {
|
onStop = {
|
||||||
runBusy("停止 scrcpy") {
|
runBusy("停止 scrcpy") {
|
||||||
nativeCore.scrcpyStop()
|
scrcpy.stop()
|
||||||
sessionInfo = null
|
sessionInfo = null
|
||||||
statusLine =
|
statusLine = "${currentTarget!!.host}:${currentTarget.port}"
|
||||||
currentTarget?.let { "${it.host}:${it.port}" } ?: "ADB 已连接"
|
|
||||||
logEvent("scrcpy 已停止")
|
logEvent("scrcpy 已停止")
|
||||||
scope.launch {
|
scope.launch {
|
||||||
snack.showSnackbar("scrcpy 已停止")
|
snack.showSnackbar("scrcpy 已停止")
|
||||||
@@ -1333,9 +1317,9 @@ fun DeviceTabScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (eventLog.isNotEmpty()) item {
|
if (EventLogger.hasLogs()) item {
|
||||||
Spacer(Modifier.height(UiSpacing.PageItem))
|
Spacer(Modifier.height(UiSpacing.PageItem))
|
||||||
LogsPanel(lines = eventLog)
|
LogsPanel(lines = EventLogger.eventLog)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: 放进 [AppPageLazyColumn] 里
|
// TODO: 放进 [AppPageLazyColumn] 里
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import androidx.core.view.WindowCompat
|
|||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.core.view.WindowInsetsControllerCompat
|
import androidx.core.view.WindowInsetsControllerCompat
|
||||||
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
|
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
|
||||||
import io.github.miuzarte.scrcpyforandroid.ScrcpySessionInfo
|
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade.ScrcpySessionInfo
|
||||||
import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
|
import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
|
||||||
import io.github.miuzarte.scrcpyforandroid.widgets.FullscreenControlScreen
|
import io.github.miuzarte.scrcpyforandroid.widgets.FullscreenControlScreen
|
||||||
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
|
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
|
||||||
@@ -113,9 +113,9 @@ fun FullscreenControlPage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendKeycode(keycode: Int) {
|
suspend fun sendKeycode(keycode: Int) {
|
||||||
nativeCore.scrcpyInjectKeycode(0, keycode)
|
nativeCore.sessionManager.injectKeycode(0, keycode)
|
||||||
nativeCore.scrcpyInjectKeycode(1, keycode)
|
nativeCore.sessionManager.injectKeycode(1, keycode)
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(contentWindowInsets = WindowInsets(0, 0, 0, 0)) { contentPadding ->
|
Scaffold(contentWindowInsets = WindowInsets(0, 0, 0, 0)) { contentPadding ->
|
||||||
@@ -132,7 +132,7 @@ fun FullscreenControlPage(
|
|||||||
currentFps = currentFps,
|
currentFps = currentFps,
|
||||||
enableBackHandler = false,
|
enableBackHandler = false,
|
||||||
onInjectTouch = { action, pointerId, x, y, pressure, buttons ->
|
onInjectTouch = { action, pointerId, x, y, pressure, buttons ->
|
||||||
nativeCore.scrcpyInjectTouch(
|
nativeCore.sessionManager.injectTouch(
|
||||||
action = action,
|
action = action,
|
||||||
pointerId = pointerId,
|
pointerId = pointerId,
|
||||||
x = x,
|
x = x,
|
||||||
@@ -150,7 +150,9 @@ fun FullscreenControlPage(
|
|||||||
bar.Fullscreen(
|
bar.Fullscreen(
|
||||||
modifier = Modifier.align(Alignment.BottomCenter),
|
modifier = Modifier.align(Alignment.BottomCenter),
|
||||||
onAction = { action ->
|
onAction = { action ->
|
||||||
action.keycode?.let(::sendKeycode)
|
action.keycode?.let {
|
||||||
|
sendKeycode(it)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
|
|||||||
import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults
|
import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults
|
||||||
import io.github.miuzarte.scrcpyforandroid.constants.UiMotion
|
import io.github.miuzarte.scrcpyforandroid.constants.UiMotion
|
||||||
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
|
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
||||||
import io.github.miuzarte.scrcpyforandroid.services.MainSettings
|
import io.github.miuzarte.scrcpyforandroid.services.MainSettings
|
||||||
import io.github.miuzarte.scrcpyforandroid.services.loadDevicePageSettings
|
import io.github.miuzarte.scrcpyforandroid.services.loadDevicePageSettings
|
||||||
import io.github.miuzarte.scrcpyforandroid.services.loadMainSettings
|
import io.github.miuzarte.scrcpyforandroid.services.loadMainSettings
|
||||||
@@ -82,7 +84,7 @@ private enum class MainTabDestination(
|
|||||||
val icon: ImageVector,
|
val icon: ImageVector,
|
||||||
) {
|
) {
|
||||||
Device(title = "设备", label = "设备", icon = Icons.Rounded.Devices),
|
Device(title = "设备", label = "设备", icon = Icons.Rounded.Devices),
|
||||||
Settings(title = "设置", label = "设置", icon = Icons.Rounded.Settings),
|
Settings(title = "设置", label = "设置", icon = Icons.Rounded.Settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed interface RootScreen : NavKey {
|
private sealed interface RootScreen : NavKey {
|
||||||
@@ -99,7 +101,11 @@ fun MainPage() {
|
|||||||
val initialOrientation = remember(activity) {
|
val initialOrientation = remember(activity) {
|
||||||
activity?.requestedOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
activity?.requestedOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||||
}
|
}
|
||||||
|
|
||||||
val nativeCore = remember(context) { NativeCoreFacade.get(context.applicationContext) }
|
val nativeCore = remember(context) { NativeCoreFacade.get(context.applicationContext) }
|
||||||
|
val adbService = remember(context) { NativeAdbService(context) }
|
||||||
|
val scrcpy = remember(context) { Scrcpy(context) }
|
||||||
|
|
||||||
val initialSettings = remember(context) { loadMainSettings(context) }
|
val initialSettings = remember(context) { loadMainSettings(context) }
|
||||||
val initialDeviceSettings = remember(context) { loadDevicePageSettings(context) }
|
val initialDeviceSettings = remember(context) { loadDevicePageSettings(context) }
|
||||||
val snackHostState = remember { SnackbarHostState() }
|
val snackHostState = remember { SnackbarHostState() }
|
||||||
@@ -272,7 +278,7 @@ fun MainPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(adbKeyName) {
|
LaunchedEffect(adbKeyName) {
|
||||||
nativeCore.setAdbKeyName(adbKeyName.ifBlank { AppDefaults.ADB_KEY_NAME })
|
adbService.keyName = adbKeyName.ifBlank { AppDefaults.ADB_KEY_NAME }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun popRoot() {
|
fun popRoot() {
|
||||||
@@ -308,8 +314,8 @@ fun MainPage() {
|
|||||||
lastExitBackPressAtMs = 0L
|
lastExitBackPressAtMs = 0L
|
||||||
scope.launch {
|
scope.launch {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
runCatching { nativeCore.scrcpyStop() }
|
runCatching { scrcpy.stop() }
|
||||||
runCatching { nativeCore.adbDisconnect() }
|
runCatching { adbService.disconnect() }
|
||||||
}
|
}
|
||||||
activity?.finish()
|
activity?.finish()
|
||||||
}
|
}
|
||||||
@@ -422,15 +428,14 @@ fun MainPage() {
|
|||||||
DeviceTabScreen(
|
DeviceTabScreen(
|
||||||
contentPadding = pagePadding,
|
contentPadding = pagePadding,
|
||||||
nativeCore = nativeCore,
|
nativeCore = nativeCore,
|
||||||
|
adbService = adbService,
|
||||||
|
scrcpy = scrcpy,
|
||||||
snack = snackHostState,
|
snack = snackHostState,
|
||||||
scrollBehavior = deviceScrollBehavior,
|
scrollBehavior = deviceScrollBehavior,
|
||||||
virtualButtonsLayout = virtualButtonsLayout,
|
virtualButtonsLayout = virtualButtonsLayout,
|
||||||
showPreviewVirtualButtonText = showPreviewVirtualButtonText,
|
showPreviewVirtualButtonText = showPreviewVirtualButtonText,
|
||||||
previewCardHeightDp = devicePreviewCardHeightDp,
|
previewCardHeightDp = devicePreviewCardHeightDp,
|
||||||
themeBaseIndex = themeBaseIndex,
|
themeBaseIndex = themeBaseIndex,
|
||||||
customServerUri = customServerUri,
|
|
||||||
serverRemotePath = serverRemotePath,
|
|
||||||
onServerRemotePathChange = { serverRemotePath = it },
|
|
||||||
videoCodec = videoCodec,
|
videoCodec = videoCodec,
|
||||||
onVideoCodecChange = { videoCodec = it },
|
onVideoCodecChange = { videoCodec = it },
|
||||||
audioEnabled = audioEnabled,
|
audioEnabled = audioEnabled,
|
||||||
|
|||||||
@@ -0,0 +1,549 @@
|
|||||||
|
package io.github.miuzarte.scrcpyforandroid.scrcpy
|
||||||
|
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.AudioSource
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.CameraFacing
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.DisplayImePolicy
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.ListOptions
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.LogLevel
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Orientation
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.OrientationLock
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Tick
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.VideoSource
|
||||||
|
|
||||||
|
// TODO: 修改配置项时, validate() 判断是否合法, 非法则回退修改
|
||||||
|
|
||||||
|
// TODO: 管理冲突配置项的关系, 在更多参数页中隐藏冲突项
|
||||||
|
|
||||||
|
// TODO: 禁用配置项验证, 包括不隐藏冲突配置项
|
||||||
|
|
||||||
|
// TODO: 配置切换
|
||||||
|
|
||||||
|
// TODO: 参数预览框可编辑
|
||||||
|
|
||||||
|
data class ClientOptions(
|
||||||
|
// var serial: String = "", // to server
|
||||||
|
|
||||||
|
// --crop=width:height:x:y
|
||||||
|
var crop: String = "", // to server
|
||||||
|
|
||||||
|
// TODO
|
||||||
|
// --record
|
||||||
|
var recordFilename: String = "",
|
||||||
|
|
||||||
|
// var windowTitle: String = "",
|
||||||
|
// var pushTarget: String = "",
|
||||||
|
// var renderDriver: String = "",
|
||||||
|
|
||||||
|
// --video-codec-options
|
||||||
|
var videoCodecOptions: String = "", // to server
|
||||||
|
// --audio-codec-options
|
||||||
|
var audioCodecOptions: String = "", // to server
|
||||||
|
// --video-encoder
|
||||||
|
var videoEncoder: String = "", // to server
|
||||||
|
// --audio-encoder
|
||||||
|
var audioEncoder: String = "", // to server
|
||||||
|
|
||||||
|
// --camera-id
|
||||||
|
var cameraId: String = "", // to server
|
||||||
|
// --camera-size
|
||||||
|
var cameraSize: String = "", // to server
|
||||||
|
// --camera-ar
|
||||||
|
var cameraAr: String = "", // to server
|
||||||
|
// --camera-fps
|
||||||
|
var cameraFps: UShort = 0u, // to server
|
||||||
|
|
||||||
|
var logLevel: LogLevel = LogLevel.INFO, // to server
|
||||||
|
|
||||||
|
// --video-codec
|
||||||
|
var videoCodec: Codec = Codec.H264, // to server
|
||||||
|
// --audio-codec
|
||||||
|
var audioCodec: Codec = Codec.OPUS, // to server
|
||||||
|
// --video-source
|
||||||
|
var videoSource: VideoSource = VideoSource.DISPLAY, // to server
|
||||||
|
// --audio-source
|
||||||
|
var audioSource: AudioSource = AudioSource.AUTO, // to server
|
||||||
|
|
||||||
|
// --record-format
|
||||||
|
var recordFormat: RecordFormat = RecordFormat.AUTO,
|
||||||
|
|
||||||
|
// var keyboardInputMode: KeyboardInputMode,
|
||||||
|
// var mouseInputMode: MouseInputMode,
|
||||||
|
// var gamepadInputMode: GamepadInputMode,
|
||||||
|
|
||||||
|
// sc_mouse_bindings(pri, sec)
|
||||||
|
// var mouseBindings: MouseBindings,
|
||||||
|
|
||||||
|
// --camera-facing
|
||||||
|
var cameraFacing: CameraFacing = CameraFacing.ANY, // to server
|
||||||
|
|
||||||
|
// var portRange: PortRange, // sc_port_range(first, last) // to server
|
||||||
|
// var tunnelHost: UInt, // to server
|
||||||
|
// var tunnelPort: UShort, // to server
|
||||||
|
|
||||||
|
// OR of enum sc_shortcut_mod values
|
||||||
|
// var shortcutMods: ShortcutMod,
|
||||||
|
|
||||||
|
// --max-size
|
||||||
|
var maxSize: UShort = 0u, // to server
|
||||||
|
|
||||||
|
// --video-bit-rate
|
||||||
|
var videoBitRate: UInt = 0u, // to server
|
||||||
|
// --audio-bit-rate
|
||||||
|
var audioBitRate: UInt = 0u, // to server
|
||||||
|
|
||||||
|
// --max-fps
|
||||||
|
var maxFps: String = "", // float to be parsed by the server
|
||||||
|
var angle: String = "", // float to be parsed by the server
|
||||||
|
|
||||||
|
// --capture-orientation=.*
|
||||||
|
var captureOrientation: Orientation = Orientation.ORIENT_0, // to server
|
||||||
|
// --capture-orientation=@.*
|
||||||
|
var captureOrientationLock: OrientationLock = OrientationLock.UNLOCKED, // to server
|
||||||
|
|
||||||
|
// --display-orientation
|
||||||
|
var displayOrientation: Orientation = Orientation.ORIENT_0,
|
||||||
|
// --record-orientation
|
||||||
|
var recordOrientation: Orientation = Orientation.ORIENT_0,
|
||||||
|
|
||||||
|
// --display-ime-policy
|
||||||
|
var displayImePolicy: DisplayImePolicy = DisplayImePolicy.UNDEFINED, // to server
|
||||||
|
|
||||||
|
// var windowX: Short,
|
||||||
|
// var windowY: Short,
|
||||||
|
// var windowWidth: UShort,
|
||||||
|
// var windowHeight: UShort,
|
||||||
|
|
||||||
|
// --display-id
|
||||||
|
var displayId: UInt = 0u, // to server
|
||||||
|
|
||||||
|
// var videoBuffer: Tick,
|
||||||
|
// var audioBuffer: Tick,
|
||||||
|
// var audioOutputBuffer: Tick,
|
||||||
|
// var timeLimit: Tick,
|
||||||
|
|
||||||
|
// --screen-off-timeout
|
||||||
|
var screenOffTimeout: Tick = Tick(-1), // to server
|
||||||
|
|
||||||
|
// var otg: Boolean,
|
||||||
|
|
||||||
|
// --show-touches
|
||||||
|
var showTouches: Boolean = false, // to server
|
||||||
|
// 开始后立即进入全屏
|
||||||
|
// --fullscreen
|
||||||
|
var fullscreen: Boolean = false,
|
||||||
|
|
||||||
|
// var alwaysOnTop: Boolean,
|
||||||
|
|
||||||
|
// --no-control
|
||||||
|
var control: Boolean = true, // to server
|
||||||
|
// --no-playback
|
||||||
|
// --no-video-playback
|
||||||
|
var videoPlayback: Boolean = true,
|
||||||
|
// --no-playback
|
||||||
|
// --no-audio-playback
|
||||||
|
var audioPlayback: Boolean = true,
|
||||||
|
// --turn-screen-off
|
||||||
|
var turnScreenOff: Boolean = false,
|
||||||
|
|
||||||
|
// [sc_key_inject_mode]
|
||||||
|
// var keyInjectMode: KeyInjectMode,
|
||||||
|
|
||||||
|
// var windowBorderless: Boolean,
|
||||||
|
// var mipmaps: Boolean,
|
||||||
|
|
||||||
|
// --stay-awake
|
||||||
|
var stayAwake: Boolean = false, // to server
|
||||||
|
|
||||||
|
// --force-adb-forward
|
||||||
|
// var forceAdbForward: Boolean, // to server
|
||||||
|
|
||||||
|
// --disable-screensaver
|
||||||
|
var disableScreensaver: Boolean = false,
|
||||||
|
|
||||||
|
// var forwardKeyRepeat: Boolean,
|
||||||
|
// var legacyPaste: Boolean,
|
||||||
|
|
||||||
|
// --power-off-on-close
|
||||||
|
var powerOffOnClose: Boolean = false, // to server
|
||||||
|
|
||||||
|
// --no-clipboard-autosync
|
||||||
|
// var clipboardAutosync: Boolean = true, // to server
|
||||||
|
|
||||||
|
// --no-downsize-on-error
|
||||||
|
// var downsizeOnError: Boolean = true, // to server
|
||||||
|
|
||||||
|
// var tcpip: Boolean, // to server
|
||||||
|
// var tcpipDst: String = "", // to server
|
||||||
|
// var selectUsb: Boolean, // to server
|
||||||
|
// var selectTcpip: Boolean, // to server
|
||||||
|
|
||||||
|
// --no-cleanup
|
||||||
|
var cleanUp: Boolean = true, // to server
|
||||||
|
|
||||||
|
// var startFpsCounter: Boolean,
|
||||||
|
|
||||||
|
// --no-power-on
|
||||||
|
var powerOn: Boolean = true, // to server
|
||||||
|
// --no-video
|
||||||
|
var video: Boolean = true, // to server
|
||||||
|
// --no-audio
|
||||||
|
var audio: Boolean = true, // to server
|
||||||
|
// --require-audio
|
||||||
|
var requireAudio: Boolean = false,
|
||||||
|
|
||||||
|
// 结束 scrcpy 后断开 adb 连接
|
||||||
|
// --kill-adb-on-close
|
||||||
|
var killAdbOnClose: Boolean = false, // to server but client side
|
||||||
|
// --camera-high-speed
|
||||||
|
var cameraHighSpeed: Boolean = false, // to server
|
||||||
|
|
||||||
|
var list: ListOptions = ListOptions.NULL, // to server
|
||||||
|
|
||||||
|
// --no-window
|
||||||
|
// var window: Boolean,
|
||||||
|
|
||||||
|
// --no-mouse-hover
|
||||||
|
// var mouseHover: Boolean = true,
|
||||||
|
|
||||||
|
// --audio-dup
|
||||||
|
var audioDup: Boolean = false, // to server
|
||||||
|
// --new-display=[<width>x<height>][/<dpi>]
|
||||||
|
var newDisplay: String = "", // to server
|
||||||
|
// --start-app
|
||||||
|
var startApp: String = "",
|
||||||
|
|
||||||
|
// --no-vd-destroy-content
|
||||||
|
var vdDestroyContent: Boolean = true, // to server
|
||||||
|
// --no-vd-system-decorations
|
||||||
|
var vdSystemDecorations: Boolean = true, // to server
|
||||||
|
) {
|
||||||
|
enum class RecordFormat(val string: String) {
|
||||||
|
AUTO("auto"), // ignore
|
||||||
|
MP4("mp4"),
|
||||||
|
MKV("mkv"),
|
||||||
|
M4A("m4a"),
|
||||||
|
MKA("mka"),
|
||||||
|
OPUS("opus"),
|
||||||
|
AAC("aac"),
|
||||||
|
FLAC("flac"),
|
||||||
|
WAV("wav");
|
||||||
|
|
||||||
|
fun isAudioOnly(): Boolean = when (this) {
|
||||||
|
M4A, MKA, OPUS, AAC, FLAC, WAV -> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun validate() {
|
||||||
|
if (!video) {
|
||||||
|
videoPlayback = false
|
||||||
|
powerOn = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!audio) {
|
||||||
|
audioPlayback = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (video && !videoPlayback && recordFilename.isBlank()) {
|
||||||
|
video = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audio && !audioPlayback && recordFilename.isBlank()) {
|
||||||
|
audio = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!video && !audio && !control) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"nothing to do"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!video) {
|
||||||
|
requireAudio = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newDisplay.isNotBlank()) {
|
||||||
|
if (videoSource != VideoSource.DISPLAY) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"--new-display is only available with --video-source=display"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!video) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"--new-display is incompatible with --no-video"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (videoSource == VideoSource.CAMERA) {
|
||||||
|
if (displayId > 0u) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"--display-id is only available with --video-source=display"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (displayImePolicy != DisplayImePolicy.UNDEFINED) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"--display-ime-policy is only available with --video-source=display"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cameraId.isNotBlank() && cameraFacing != CameraFacing.ANY) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"Cannot specify both --camera-id and --camera-facing"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cameraSize.isNotBlank()) {
|
||||||
|
if (maxSize > 0u) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"Cannot specify both --camera-size and -m/--max-size"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cameraAr.isNotBlank()) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"--camera-high-speed requires an explicit --camera-fps value"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cameraHighSpeed && cameraFps > 0u) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"--camera-high-speed requires an explicit --camera-fps value"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (control) {
|
||||||
|
control = false
|
||||||
|
}
|
||||||
|
} else if (cameraId.isNotBlank()
|
||||||
|
|| cameraAr.isNotBlank()
|
||||||
|
|| cameraFacing != CameraFacing.ANY
|
||||||
|
|| cameraFps > 0u
|
||||||
|
|| cameraHighSpeed
|
||||||
|
|| cameraSize.isNotBlank()
|
||||||
|
) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"Camera options are only available with --video-source=camera"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (displayId > 0u && newDisplay.isNotBlank()) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"Cannot specify both --display-id and --new-display"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (displayImePolicy != DisplayImePolicy.UNDEFINED
|
||||||
|
&& displayId == 0u && newDisplay.isBlank()
|
||||||
|
) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"--display-ime-policy is only supported on a secondary display"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audio && audioSource == AudioSource.AUTO) {
|
||||||
|
// Select the audio source according to the video source
|
||||||
|
audioSource = if (videoSource == VideoSource.DISPLAY) {
|
||||||
|
if (audioDup) {
|
||||||
|
AudioSource.PLAYBACK
|
||||||
|
} else {
|
||||||
|
AudioSource.OUTPUT
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
AudioSource.MIC
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audioDup) {
|
||||||
|
if (!audio) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"--audio-dup not supported if audio is disabled"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audioSource != AudioSource.PLAYBACK) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"--audio-dup is specific to --audio-source=playback"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recordFormat != RecordFormat.AUTO && recordFilename.isNotBlank()) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"Record format specified without recording"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recordFilename.isNotBlank()) {
|
||||||
|
if (!video && !audio) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"Video and audio disabled, nothing to record"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recordFormat == RecordFormat.AUTO) {
|
||||||
|
// TODO: guess from recordFilename
|
||||||
|
recordFormat = RecordFormat.MP4
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recordOrientation != Orientation.ORIENT_0) {
|
||||||
|
if (recordOrientation.isMirror()) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"Record orientation only supports rotation, " +
|
||||||
|
"not flipping: ${recordOrientation.string}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (video && recordFormat.isAudioOnly()) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"Audio container does not support video stream"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recordFormat == RecordFormat.OPUS && audioCodec != Codec.OPUS) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"Recording to OPUS file requires an OPUS audio stream " +
|
||||||
|
"(try with --audio-codec=opus)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recordFormat == RecordFormat.AAC && audioCodec != Codec.AAC) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"Recording to AAC file requires an AAC audio stream " +
|
||||||
|
"(try with --audio-codec=aac)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recordFormat == RecordFormat.FLAC && audioCodec != Codec.FLAC) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"Recording to FLAC file requires an FLAC audio stream " +
|
||||||
|
"(try with --audio-codec=flac)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recordFormat == RecordFormat.WAV && audioCodec != Codec.RAW) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"Recording to WAV file requires an RAW audio stream " +
|
||||||
|
"(try with --audio-codec=raw)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((recordFormat == RecordFormat.MP4 || recordFormat == RecordFormat.M4A)
|
||||||
|
&& audioCodec == Codec.RAW
|
||||||
|
) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"Recording to MP4 container does not support RAW audio"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
if (audioCodec == Codec.FLAC && audioBitRate > 0u) {
|
||||||
|
// "--audio-bit-rate is ignored for FLAC audio codec"
|
||||||
|
// audioBitRate
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
if (audioCodec == Codec.RAW) {
|
||||||
|
if (audioBitRate > 0u) {
|
||||||
|
// "--audio-bit-rate is ignored for raw audio codec"
|
||||||
|
// audioBitRate
|
||||||
|
}
|
||||||
|
if (audioCodecOptions.isNotBlank()) {
|
||||||
|
// "--audio-codec-options is ignored for raw audio codec"
|
||||||
|
// audioCodecOptions
|
||||||
|
}
|
||||||
|
if (audioEncoder.isNotBlank()) {
|
||||||
|
// "--audio-encoder is ignored for raw audio codec"
|
||||||
|
// audioEncoder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (control) {
|
||||||
|
if (turnScreenOff) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"Cannot request to turn screen off if control is disabled"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (stayAwake) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"Cannot request to stay awake if control is disabled"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showTouches) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"Cannot request to show touches if control is disabled"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (powerOffOnClose) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"Cannot request power off on close if control is disabled"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (startApp.isNotBlank()) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"Cannot start an Android app if control is disabled"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toServerParams(scid: UInt): ServerParams {
|
||||||
|
return ServerParams(
|
||||||
|
scid = scid,
|
||||||
|
|
||||||
|
logLevel = logLevel,
|
||||||
|
videoCodec = videoCodec,
|
||||||
|
audioCodec = audioCodec,
|
||||||
|
videoSource = videoSource,
|
||||||
|
audioSource = audioSource,
|
||||||
|
cameraFacing = cameraFacing,
|
||||||
|
crop = crop,
|
||||||
|
|
||||||
|
maxSize = maxSize,
|
||||||
|
videoBitRate = videoBitRate,
|
||||||
|
audioBitRate = audioBitRate,
|
||||||
|
maxFps = maxFps,
|
||||||
|
angle = angle,
|
||||||
|
screenOffTimeout = screenOffTimeout,
|
||||||
|
captureOrientation = captureOrientation,
|
||||||
|
captureOrientationLock = captureOrientationLock,
|
||||||
|
control = control,
|
||||||
|
displayId = displayId,
|
||||||
|
newDisplay = newDisplay,
|
||||||
|
displayImePolicy = displayImePolicy,
|
||||||
|
video = video,
|
||||||
|
audio = audio,
|
||||||
|
audioDup = audioDup,
|
||||||
|
showTouches = showTouches,
|
||||||
|
stayAwake = stayAwake,
|
||||||
|
videoCodecOptions = videoCodecOptions,
|
||||||
|
audioCodecOptions = audioCodecOptions,
|
||||||
|
videoEncoder = videoEncoder,
|
||||||
|
audioEncoder = audioEncoder,
|
||||||
|
cameraId = cameraId,
|
||||||
|
cameraSize = cameraSize,
|
||||||
|
cameraAr = cameraAr,
|
||||||
|
cameraFps = cameraFps,
|
||||||
|
|
||||||
|
powerOffOnClose = powerOffOnClose,
|
||||||
|
|
||||||
|
cleanUp = cleanUp,
|
||||||
|
powerOn = powerOn,
|
||||||
|
|
||||||
|
// killAdbOnClose == killAdbOnClose, // client side
|
||||||
|
|
||||||
|
cameraHighSpeed = cameraHighSpeed,
|
||||||
|
vdDestroyContent = vdDestroyContent,
|
||||||
|
vdSystemDecorations = vdSystemDecorations,
|
||||||
|
list = list,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,424 @@
|
|||||||
|
package io.github.miuzarte.scrcpyforandroid.scrcpy
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade.ScrcpySessionInfo
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.nativecore.ScrcpyAudioPlayer
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.nativecore.ScrcpySessionManager
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.ListOptions
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent
|
||||||
|
import java.io.File
|
||||||
|
import kotlin.random.Random
|
||||||
|
import kotlin.random.nextUInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* High-level scrcpy client API.
|
||||||
|
*
|
||||||
|
* Manages scrcpy sessions including:
|
||||||
|
* - Server jar extraction and deployment
|
||||||
|
* - Session lifecycle (start/stop)
|
||||||
|
* - Audio playback
|
||||||
|
* - Screen control
|
||||||
|
*
|
||||||
|
* @param context Android context
|
||||||
|
* @param serverAsset Asset path for the default server jar
|
||||||
|
* @param customServerUri Optional custom server URI (overrides serverAsset)
|
||||||
|
* @param serverVersion Server version string
|
||||||
|
* @param serverRemotePath Remote path where server jar will be pushed on device
|
||||||
|
*/
|
||||||
|
class Scrcpy(
|
||||||
|
private val context: Context,
|
||||||
|
private val serverAsset: String = DEFAULT_SERVER_ASSET,
|
||||||
|
private val customServerUri: String? = null,
|
||||||
|
private val serverVersion: String = "3.3.4",
|
||||||
|
private val serverRemotePath: String = DEFAULT_REMOTE_PATH,
|
||||||
|
) {
|
||||||
|
private val adbService = NativeAdbService(context)
|
||||||
|
private val sessionManager = ScrcpySessionManager(adbService)
|
||||||
|
private val nativeCore: NativeCoreFacade = NativeCoreFacade.get(context)
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var currentSession: ScrcpySessionInfo? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var isRunning: Boolean = false
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var audioPlayer: ScrcpyAudioPlayer? = null
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "Scrcpy"
|
||||||
|
const val DEFAULT_REMOTE_PATH = "/data/local/tmp/scrcpy-server.jar"
|
||||||
|
const val DEFAULT_SERVER_ASSET = "bin/scrcpy-server-v3.3.4"
|
||||||
|
|
||||||
|
// Regex patterns for parsing server output
|
||||||
|
private val VIDEO_ENCODER_REGEX = Regex("--video-encoder=([\\w.\\-]+)")
|
||||||
|
private val AUDIO_ENCODER_REGEX = Regex("--audio-encoder=([\\w.\\-]+)")
|
||||||
|
private val VIDEO_ENCODER_FALLBACK_REGEX = Regex("""--video-encoder=['"]?([^'"\s]+)""")
|
||||||
|
private val AUDIO_ENCODER_FALLBACK_REGEX = Regex("""--audio-encoder=['"]?([^'"\s]+)""")
|
||||||
|
private val VIDEO_ENCODER_TYPE_REGEX =
|
||||||
|
Regex("""--video-codec=\S+\s+--video-encoder=(\S+).*?\((hw|sw)\)""")
|
||||||
|
private val AUDIO_ENCODER_TYPE_REGEX =
|
||||||
|
Regex("""--audio-codec=\S+\s+--audio-encoder=(\S+).*?\((hw|sw)\)""")
|
||||||
|
private val CAMERA_SIZE_REGEX = Regex("--camera-size=([0-9]+x[0-9]+)")
|
||||||
|
private val CAMERA_SIZE_FALLBACK_REGEX = Regex("\\b([1-9][0-9]{1,4}x[1-9][0-9]{1,4})\\b")
|
||||||
|
private const val PREVIEW_LINES = 32
|
||||||
|
|
||||||
|
fun generateScid(): UInt {
|
||||||
|
// Only use 31 bits to avoid issues with signed values on the Java-side
|
||||||
|
return (Random.nextUInt() and 0x7FFFFFFFu)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun start(
|
||||||
|
options: ClientOptions,
|
||||||
|
): ScrcpySessionInfo {
|
||||||
|
if (isRunning) {
|
||||||
|
throw IllegalStateException("Scrcpy session is already running")
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.i(TAG, "Initializing scrcpy session")
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validate options
|
||||||
|
options.validate()
|
||||||
|
|
||||||
|
// Generate session ID
|
||||||
|
val scid = generateScid()
|
||||||
|
Log.d(TAG, "scid=0x${scid.toString(16)}")
|
||||||
|
|
||||||
|
val serverJar = if (customServerUri.isNullOrBlank()) {
|
||||||
|
extractAssetToCache(serverAsset)
|
||||||
|
} else {
|
||||||
|
extractUriToCache(customServerUri.toUri())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute server
|
||||||
|
val info = executeServer(
|
||||||
|
serverJar = serverJar,
|
||||||
|
options = options,
|
||||||
|
scid = scid,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Turn screen off if requested
|
||||||
|
if (options.turnScreenOff) {
|
||||||
|
if (!options.control) {
|
||||||
|
Log.w(TAG, "start(): turnScreenOff ignored because control is disabled")
|
||||||
|
} else {
|
||||||
|
runCatching { sessionManager.setDisplayPower(on = false) }
|
||||||
|
.onFailure { e -> Log.w(TAG, "start(): set display power failed", e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create session info
|
||||||
|
val session = ScrcpySessionInfo(
|
||||||
|
width = info.width,
|
||||||
|
height = info.height,
|
||||||
|
deviceName = info.deviceName,
|
||||||
|
codec = info.codecName,
|
||||||
|
controlEnabled = info.controlEnabled,
|
||||||
|
)
|
||||||
|
currentSession = session
|
||||||
|
isRunning = true
|
||||||
|
|
||||||
|
// Setup video consumer (notify NativeCoreFacade to setup decoders)
|
||||||
|
if (options.video) {
|
||||||
|
nativeCore.onScrcpySessionStarted(session, sessionManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup audio player
|
||||||
|
audioPlayer?.release()
|
||||||
|
audioPlayer = null
|
||||||
|
if (info.audioCodecId != 0 && options.audioPlayback) {
|
||||||
|
Log.i(
|
||||||
|
TAG,
|
||||||
|
"start(): create audio player codecId=0x${
|
||||||
|
info.audioCodecId.toUInt().toString(16)
|
||||||
|
}"
|
||||||
|
)
|
||||||
|
val player = ScrcpyAudioPlayer(info.audioCodecId)
|
||||||
|
audioPlayer = player
|
||||||
|
sessionManager.attachAudioConsumer { packet ->
|
||||||
|
player.feedPacket(packet.data, packet.ptsUs, packet.isConfig)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.i(TAG, "start(): audio playback disabled for this session")
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.i(
|
||||||
|
TAG, "start(): Session started successfully - device=${session.deviceName}, " +
|
||||||
|
"video=${if (options.video) "${session.codec} ${session.width}x${session.height}" else "off"}, " +
|
||||||
|
"audio=${if (options.audio) options.audioCodec.string else "off"}, " +
|
||||||
|
"control=${options.control}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return session
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "start(): Failed to start scrcpy session", e)
|
||||||
|
isRunning = false
|
||||||
|
currentSession = null
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun stop(): Boolean {
|
||||||
|
if (!isRunning) {
|
||||||
|
Log.w(TAG, "stop(): No active session to stop")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.i(TAG, "stop(): Stopping scrcpy session")
|
||||||
|
|
||||||
|
return try {
|
||||||
|
nativeCore.onScrcpySessionStopped()
|
||||||
|
sessionManager.clearVideoConsumer()
|
||||||
|
sessionManager.clearAudioConsumer()
|
||||||
|
sessionManager.stop()
|
||||||
|
audioPlayer?.release()
|
||||||
|
audioPlayer = null
|
||||||
|
isRunning = false
|
||||||
|
currentSession = null
|
||||||
|
Log.i(TAG, "stop(): Session stopped successfully")
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "stop(): Failed to stop session", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun close() {
|
||||||
|
stop()
|
||||||
|
adbService.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isStarted(): Boolean = isRunning && sessionManager.isStarted()
|
||||||
|
|
||||||
|
fun getCurrentSession(): ScrcpySessionInfo? = currentSession
|
||||||
|
|
||||||
|
fun getLastServerCommand(): String? = sessionManager.getLastServerCommand()
|
||||||
|
|
||||||
|
sealed class ListResult {
|
||||||
|
data class Encoders(
|
||||||
|
val videoEncoders: List<String>,
|
||||||
|
val audioEncoders: List<String>,
|
||||||
|
val videoEncoderTypes: Map<String, String> = emptyMap(),
|
||||||
|
val audioEncoderTypes: Map<String, String> = emptyMap(),
|
||||||
|
val rawOutput: String = "",
|
||||||
|
) : ListResult()
|
||||||
|
|
||||||
|
data class CameraSizes(
|
||||||
|
val sizes: List<String>,
|
||||||
|
val rawOutput: String = "",
|
||||||
|
) : ListResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List various options from the scrcpy server.
|
||||||
|
*
|
||||||
|
* @param list The type of list to retrieve (ENCODERS, CAMERA_SIZES, etc.)
|
||||||
|
* @return ListResult containing the requested information
|
||||||
|
*/
|
||||||
|
suspend fun listOptions(list: ListOptions): ListResult {
|
||||||
|
val serverJar = if (customServerUri.isNullOrBlank()) {
|
||||||
|
extractAssetToCache(serverAsset)
|
||||||
|
} else {
|
||||||
|
extractUriToCache(customServerUri.toUri())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push server jar to device
|
||||||
|
adbService.push(serverJar.toPath(), serverRemotePath)
|
||||||
|
|
||||||
|
val scid = generateScid()
|
||||||
|
|
||||||
|
// Create ClientOptions for listing
|
||||||
|
val options = ClientOptions(
|
||||||
|
video = false,
|
||||||
|
audio = false,
|
||||||
|
control = false,
|
||||||
|
cleanUp = false,
|
||||||
|
list = list,
|
||||||
|
)
|
||||||
|
|
||||||
|
val serverParams = options.toServerParams(scid)
|
||||||
|
|
||||||
|
// Build server command
|
||||||
|
val serverCommand = serverParams.build(
|
||||||
|
"CLASSPATH=$serverRemotePath",
|
||||||
|
"app_process",
|
||||||
|
"/",
|
||||||
|
"com.genymobile.scrcpy.Server",
|
||||||
|
serverVersion,
|
||||||
|
)
|
||||||
|
|
||||||
|
Log.i(TAG, "listOptions(): cmd=$serverCommand")
|
||||||
|
|
||||||
|
// Execute shell command and capture output (merge stderr into stdout)
|
||||||
|
val output = adbService.shell("$serverCommand 2>&1")
|
||||||
|
|
||||||
|
// Parse output based on list option
|
||||||
|
return when (list) {
|
||||||
|
ListOptions.NULL -> {
|
||||||
|
throw IllegalArgumentException("Nothing to do with ListOptions.NULL")
|
||||||
|
}
|
||||||
|
|
||||||
|
ListOptions.ENCODERS -> {
|
||||||
|
val parsed = parseEncoderLists(output)
|
||||||
|
val preview = output.lineSequence().take(PREVIEW_LINES).joinToString("\n")
|
||||||
|
Log.i(
|
||||||
|
TAG,
|
||||||
|
"listOptions(ENCODERS): parsed video=${parsed.videoEncoders.size} audio=${parsed.audioEncoders.size}, outputPreview=\n$preview",
|
||||||
|
)
|
||||||
|
ListResult.Encoders(
|
||||||
|
videoEncoders = parsed.videoEncoders,
|
||||||
|
audioEncoders = parsed.audioEncoders,
|
||||||
|
videoEncoderTypes = parsed.videoEncoderTypes,
|
||||||
|
audioEncoderTypes = parsed.audioEncoderTypes,
|
||||||
|
rawOutput = output,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ListOptions.DISPLAYS -> {
|
||||||
|
throw Exception("TODO")
|
||||||
|
}
|
||||||
|
|
||||||
|
ListOptions.CAMERAS -> {
|
||||||
|
throw Exception("TODO")
|
||||||
|
}
|
||||||
|
|
||||||
|
ListOptions.CAMERA_SIZES -> {
|
||||||
|
val parsed = parseCameraSizeLists(output)
|
||||||
|
val preview = output.lineSequence().take(PREVIEW_LINES).joinToString("\n")
|
||||||
|
Log.i(
|
||||||
|
TAG,
|
||||||
|
"listOptions(CAMERA_SIZES): parsed sizes=${parsed.sizes.size}, outputPreview=\n$preview",
|
||||||
|
)
|
||||||
|
ListResult.CameraSizes(
|
||||||
|
sizes = parsed.sizes,
|
||||||
|
rawOutput = output,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
throw IllegalArgumentException("Unsupported list option: $list")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseEncoderLists(output: String): ParsedEncoders {
|
||||||
|
val video = LinkedHashSet<String>()
|
||||||
|
val audio = LinkedHashSet<String>()
|
||||||
|
val videoTypes = linkedMapOf<String, String>()
|
||||||
|
val audioTypes = linkedMapOf<String, String>()
|
||||||
|
|
||||||
|
VIDEO_ENCODER_REGEX.findAll(output).forEach { match ->
|
||||||
|
video.add(match.groupValues[1])
|
||||||
|
}
|
||||||
|
AUDIO_ENCODER_REGEX.findAll(output).forEach { match ->
|
||||||
|
audio.add(match.groupValues[1])
|
||||||
|
}
|
||||||
|
// Fallback for log formats that include codec+encoder in one line.
|
||||||
|
VIDEO_ENCODER_FALLBACK_REGEX.findAll(output).forEach { match ->
|
||||||
|
video.add(match.groupValues[1])
|
||||||
|
}
|
||||||
|
AUDIO_ENCODER_FALLBACK_REGEX.findAll(output).forEach { match ->
|
||||||
|
audio.add(match.groupValues[1])
|
||||||
|
}
|
||||||
|
VIDEO_ENCODER_TYPE_REGEX.findAll(output).forEach { match ->
|
||||||
|
val name = match.groupValues[1]
|
||||||
|
val type = match.groupValues[2]
|
||||||
|
if (name.isNotBlank() && type.isNotBlank() && !videoTypes.containsKey(name)) {
|
||||||
|
videoTypes[name] = type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AUDIO_ENCODER_TYPE_REGEX.findAll(output).forEach { match ->
|
||||||
|
val name = match.groupValues[1]
|
||||||
|
val type = match.groupValues[2]
|
||||||
|
if (name.isNotBlank() && type.isNotBlank() && !audioTypes.containsKey(name)) {
|
||||||
|
audioTypes[name] = type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ParsedEncoders(
|
||||||
|
videoEncoders = video.toList(),
|
||||||
|
audioEncoders = audio.toList(),
|
||||||
|
videoEncoderTypes = videoTypes,
|
||||||
|
audioEncoderTypes = audioTypes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseCameraSizeLists(output: String): ParsedCameraSizes {
|
||||||
|
val sizes = LinkedHashSet<String>()
|
||||||
|
CAMERA_SIZE_REGEX.findAll(output).forEach { match ->
|
||||||
|
sizes.add(match.groupValues[1])
|
||||||
|
}
|
||||||
|
CAMERA_SIZE_FALLBACK_REGEX.findAll(output).forEach { match ->
|
||||||
|
sizes.add(match.groupValues[1])
|
||||||
|
}
|
||||||
|
return ParsedCameraSizes(sizes = sizes.toList())
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class ParsedEncoders(
|
||||||
|
val videoEncoders: List<String>,
|
||||||
|
val audioEncoders: List<String>,
|
||||||
|
val videoEncoderTypes: Map<String, String>,
|
||||||
|
val audioEncoderTypes: Map<String, String>,
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class ParsedCameraSizes(
|
||||||
|
val sizes: List<String>,
|
||||||
|
)
|
||||||
|
|
||||||
|
private suspend fun executeServer(
|
||||||
|
serverJar: File,
|
||||||
|
options: ClientOptions,
|
||||||
|
scid: UInt,
|
||||||
|
): ScrcpySessionManager.SessionInfo {
|
||||||
|
adbService.push(serverJar.toPath(), serverRemotePath)
|
||||||
|
|
||||||
|
val serverParams = options.toServerParams(scid)
|
||||||
|
|
||||||
|
val serverCommand = serverParams.build(
|
||||||
|
"CLASSPATH=$serverRemotePath",
|
||||||
|
"app_process",
|
||||||
|
"/",
|
||||||
|
"com.genymobile.scrcpy.Server",
|
||||||
|
serverVersion,
|
||||||
|
)
|
||||||
|
Log.d(TAG, "Server command: $serverCommand")
|
||||||
|
|
||||||
|
// Execute server (equivalent to sc_adb_execute in C)
|
||||||
|
Log.i(TAG, "executeServer(): Starting scrcpy server")
|
||||||
|
logEvent("scrcpy-server args: $serverCommand")
|
||||||
|
return sessionManager.start(
|
||||||
|
serverJarPath = serverJar.toPath(),
|
||||||
|
serverCommand = serverCommand,
|
||||||
|
scid = scid,
|
||||||
|
options = options,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractAssetToCache(assetPath: String): File {
|
||||||
|
val clean = assetPath.removePrefix("/")
|
||||||
|
val source = context.assets.open(clean)
|
||||||
|
val outputFile = File(context.cacheDir, File(clean).name)
|
||||||
|
source.use { input ->
|
||||||
|
outputFile.outputStream().use { output -> input.copyTo(output) }
|
||||||
|
}
|
||||||
|
return outputFile
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractUriToCache(uri: Uri): File {
|
||||||
|
val fileName = "custom-scrcpy-server.jar"
|
||||||
|
val outputFile = File(context.cacheDir, fileName)
|
||||||
|
context.contentResolver.openInputStream(uri).use { input ->
|
||||||
|
requireNotNull(input) { "Unable to open selected server URI" }
|
||||||
|
outputFile.outputStream().use { output -> input.copyTo(output) }
|
||||||
|
}
|
||||||
|
return outputFile
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
package io.github.miuzarte.scrcpyforandroid.scrcpy
|
||||||
|
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.AudioSource
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.CameraFacing
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.DisplayImePolicy
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.ListOptions
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.LogLevel
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Orientation
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.OrientationLock
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Tick
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.VideoSource
|
||||||
|
|
||||||
|
// 启动 scrcpy 前直接继承自 [ClientOptions],
|
||||||
|
// 不需要默认值
|
||||||
|
data class ServerParams(
|
||||||
|
var scid: UInt, // (random uint32) & 0x7FFFFFFF
|
||||||
|
|
||||||
|
// var reqSerial: String,
|
||||||
|
|
||||||
|
var logLevel: LogLevel,
|
||||||
|
|
||||||
|
var videoCodec: Codec,
|
||||||
|
var audioCodec: Codec,
|
||||||
|
var videoSource: VideoSource,
|
||||||
|
var audioSource: AudioSource,
|
||||||
|
|
||||||
|
var cameraFacing: CameraFacing,
|
||||||
|
|
||||||
|
var crop: String,
|
||||||
|
|
||||||
|
var videoCodecOptions: String,
|
||||||
|
var audioCodecOptions: String,
|
||||||
|
var videoEncoder: String,
|
||||||
|
var audioEncoder: String,
|
||||||
|
|
||||||
|
var cameraId: String,
|
||||||
|
var cameraSize: String,
|
||||||
|
var cameraAr: String, // aspect ratio
|
||||||
|
var cameraFps: UShort,
|
||||||
|
|
||||||
|
// var portRange: PortRange, // sc_port_range(first, last)
|
||||||
|
// var tunnelHost: UInt,
|
||||||
|
// var tunnelPort: UShort,
|
||||||
|
|
||||||
|
var maxSize: UShort,
|
||||||
|
|
||||||
|
var videoBitRate: UInt,
|
||||||
|
var audioBitRate: UInt,
|
||||||
|
|
||||||
|
var maxFps: String, // float to be parsed by the server
|
||||||
|
var angle: String, // float to be parsed by the server
|
||||||
|
|
||||||
|
var screenOffTimeout: Tick,
|
||||||
|
|
||||||
|
var captureOrientation: Orientation,
|
||||||
|
var captureOrientationLock: OrientationLock,
|
||||||
|
|
||||||
|
var control: Boolean,
|
||||||
|
|
||||||
|
var displayId: UInt,
|
||||||
|
var newDisplay: String,
|
||||||
|
|
||||||
|
var displayImePolicy: DisplayImePolicy,
|
||||||
|
|
||||||
|
var video: Boolean,
|
||||||
|
var audio: Boolean,
|
||||||
|
var audioDup: Boolean,
|
||||||
|
var showTouches: Boolean,
|
||||||
|
var stayAwake: Boolean,
|
||||||
|
|
||||||
|
// var forceAdbForward: Boolean,
|
||||||
|
|
||||||
|
var powerOffOnClose: Boolean,
|
||||||
|
|
||||||
|
// var downsizeOnError: Boolean,
|
||||||
|
// var tcpip: Boolean,
|
||||||
|
// var tcpipDst: String,
|
||||||
|
// var selectUsb: Boolean,
|
||||||
|
// var selectTcpip: Boolean,
|
||||||
|
|
||||||
|
var cleanUp: Boolean,
|
||||||
|
var powerOn: Boolean,
|
||||||
|
|
||||||
|
// var killAdbOnClose: Boolean,
|
||||||
|
|
||||||
|
var cameraHighSpeed: Boolean,
|
||||||
|
|
||||||
|
var vdDestroyContent: Boolean,
|
||||||
|
var vdSystemDecorations: Boolean,
|
||||||
|
|
||||||
|
var list: ListOptions,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
const val SEPARATOR: String = " "
|
||||||
|
const val ILLEGAL_CHARACTER_SET: String = " ;'\"*$?&`#\\|<>[]{}()!~\r\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validate(str: String): Unit {
|
||||||
|
// forbid special shell characters
|
||||||
|
if (str.any { it in ILLEGAL_CHARACTER_SET }) {
|
||||||
|
throw IllegalArgumentException("Invalid server param: [$str]")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun build(vararg extraArgs: String): String {
|
||||||
|
return (extraArgs.toList() + this.toList()).joinToString(SEPARATOR)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toList(): MutableList<String> {
|
||||||
|
val cmd = mutableListOf<String>()
|
||||||
|
|
||||||
|
cmd.add("scid=${scid.toString(16)}")
|
||||||
|
cmd.add("log_level=${logLevel.string}")
|
||||||
|
|
||||||
|
if (!video) {
|
||||||
|
cmd.add("video=false")
|
||||||
|
}
|
||||||
|
if (videoBitRate > 0u) {
|
||||||
|
cmd.add("video_bit_rate=$videoBitRate")
|
||||||
|
}
|
||||||
|
if (!audio) {
|
||||||
|
cmd.add("audio=false")
|
||||||
|
}
|
||||||
|
if (audioBitRate > 0u) {
|
||||||
|
cmd.add("audio_bit_rate=$audioBitRate")
|
||||||
|
}
|
||||||
|
if (videoCodec != Codec.H264) {
|
||||||
|
cmd.add("video_codec=${videoCodec.string}")
|
||||||
|
}
|
||||||
|
if (audioCodec != Codec.OPUS) {
|
||||||
|
cmd.add("audio_codec=${audioCodec.string}")
|
||||||
|
}
|
||||||
|
if (videoSource != VideoSource.DISPLAY) {
|
||||||
|
cmd.add("video_source=${videoSource.string}")
|
||||||
|
}
|
||||||
|
// audio_source (default: output, only add if different and audio is enabled)
|
||||||
|
// Note: AUTO should have been resolved by ClientOptions.validate()
|
||||||
|
if (audioSource != AudioSource.OUTPUT && audio) {
|
||||||
|
cmd.add("audio_source=${audioSource.string}")
|
||||||
|
}
|
||||||
|
if (audioDup) {
|
||||||
|
cmd.add("audio_dup=true")
|
||||||
|
}
|
||||||
|
if (maxSize > 0u) {
|
||||||
|
cmd.add("max_size=$maxSize")
|
||||||
|
}
|
||||||
|
if (maxFps.isNotBlank()) {
|
||||||
|
validate(maxFps)
|
||||||
|
cmd.add("max_fps=${maxFps.trim()}")
|
||||||
|
}
|
||||||
|
if (angle.isNotBlank()) {
|
||||||
|
validate(angle)
|
||||||
|
cmd.add("angle=${angle.trim()}")
|
||||||
|
}
|
||||||
|
if (captureOrientationLock != OrientationLock.UNLOCKED
|
||||||
|
|| captureOrientation != Orientation.ORIENT_0
|
||||||
|
) {
|
||||||
|
when (captureOrientationLock) {
|
||||||
|
OrientationLock.LOCKED_INITIAL ->
|
||||||
|
cmd.add("capture_orientation=@")
|
||||||
|
|
||||||
|
OrientationLock.LOCKED_VALUE ->
|
||||||
|
cmd.add("capture_orientation=@${captureOrientation.string}")
|
||||||
|
|
||||||
|
OrientationLock.UNLOCKED ->
|
||||||
|
cmd.add("capture_orientation=${captureOrientation.string}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// always true cause we are using adb wireless
|
||||||
|
cmd.add("tunnel_forward=true")
|
||||||
|
if (crop.isNotBlank()) {
|
||||||
|
validate(crop)
|
||||||
|
cmd.add("crop=${crop.trim()}")
|
||||||
|
}
|
||||||
|
if (!control) {
|
||||||
|
// By default, control is true
|
||||||
|
cmd.add("control=false")
|
||||||
|
}
|
||||||
|
if (displayId > 0u) {
|
||||||
|
cmd.add("display_id=$displayId")
|
||||||
|
}
|
||||||
|
if (cameraId.isNotBlank()) {
|
||||||
|
validate(cameraId)
|
||||||
|
cmd.add("camera_id=${cameraId.trim()}")
|
||||||
|
}
|
||||||
|
if (cameraSize.isNotBlank()) {
|
||||||
|
validate(cameraSize)
|
||||||
|
cmd.add("camera_size=${cameraSize.trim()}")
|
||||||
|
}
|
||||||
|
if (cameraFacing != CameraFacing.ANY) {
|
||||||
|
cmd.add("camera_facing=${cameraFacing.string}")
|
||||||
|
}
|
||||||
|
if (cameraAr.isNotBlank()) {
|
||||||
|
validate(cameraAr)
|
||||||
|
cmd.add("camera_ar=${cameraAr.trim()}")
|
||||||
|
}
|
||||||
|
if (cameraFps > 0u) {
|
||||||
|
cmd.add("camera_fps=$cameraFps")
|
||||||
|
}
|
||||||
|
if (cameraHighSpeed) {
|
||||||
|
cmd.add("camera_high_speed=true")
|
||||||
|
}
|
||||||
|
if (showTouches) {
|
||||||
|
cmd.add("show_touches=true")
|
||||||
|
}
|
||||||
|
if (stayAwake) {
|
||||||
|
cmd.add("stay_awake=true")
|
||||||
|
}
|
||||||
|
if (screenOffTimeout.value != -1L) {
|
||||||
|
require(screenOffTimeout >= 0) {
|
||||||
|
"screen_off_timeout must be >= 0"
|
||||||
|
}
|
||||||
|
cmd.add("screen_off_timeout=${screenOffTimeout.toMs()}")
|
||||||
|
}
|
||||||
|
if (videoCodecOptions.isNotBlank()) {
|
||||||
|
validate(videoCodecOptions)
|
||||||
|
cmd.add("video_codec_options=${videoCodecOptions.trim()}")
|
||||||
|
}
|
||||||
|
if (audioCodecOptions.isNotBlank()) {
|
||||||
|
validate(audioCodecOptions)
|
||||||
|
cmd.add("audio_codec_options=${audioCodecOptions.trim()}")
|
||||||
|
}
|
||||||
|
if (videoEncoder.isNotBlank()) {
|
||||||
|
validate(videoEncoder)
|
||||||
|
cmd.add("video_encoder=${videoEncoder.trim()}")
|
||||||
|
}
|
||||||
|
if (audioEncoder.isNotBlank()) {
|
||||||
|
validate(audioEncoder)
|
||||||
|
cmd.add("audio_encoder=${audioEncoder.trim()}")
|
||||||
|
}
|
||||||
|
if (powerOffOnClose) {
|
||||||
|
cmd.add("power_off_on_close=true")
|
||||||
|
}
|
||||||
|
// not implemented
|
||||||
|
val clipBoardAutosync = false
|
||||||
|
if (!clipBoardAutosync) {
|
||||||
|
cmd.add("clipboard_autosync=false")
|
||||||
|
}
|
||||||
|
// not implemented
|
||||||
|
val downsizeOnError = false
|
||||||
|
if (!downsizeOnError) {
|
||||||
|
cmd.add("downsize_on_error=false")
|
||||||
|
}
|
||||||
|
if (!cleanUp) {
|
||||||
|
// By default, cleanup is true
|
||||||
|
cmd.add("cleanup=false")
|
||||||
|
}
|
||||||
|
if (!powerOn) {
|
||||||
|
// By default, power_on is true
|
||||||
|
cmd.add("power_on=false")
|
||||||
|
}
|
||||||
|
if (newDisplay.isNotBlank()) {
|
||||||
|
validate(newDisplay)
|
||||||
|
cmd.add("new_display=${newDisplay.trim()}")
|
||||||
|
}
|
||||||
|
if (displayImePolicy != DisplayImePolicy.UNDEFINED) {
|
||||||
|
cmd.add("display_ime_policy=${displayImePolicy.string}")
|
||||||
|
}
|
||||||
|
if (!vdDestroyContent) {
|
||||||
|
cmd.add("vd_destroy_content=false")
|
||||||
|
}
|
||||||
|
if (!vdSystemDecorations) {
|
||||||
|
cmd.add("vd_system_decorations=false")
|
||||||
|
}
|
||||||
|
if (list has ListOptions.ENCODERS) {
|
||||||
|
cmd.add("list_encoders=true")
|
||||||
|
}
|
||||||
|
if (list has ListOptions.DISPLAYS) {
|
||||||
|
cmd.add("list_displays=true")
|
||||||
|
}
|
||||||
|
if (list has ListOptions.CAMERAS) {
|
||||||
|
cmd.add("list_cameras=true")
|
||||||
|
}
|
||||||
|
if (list has ListOptions.CAMERA_SIZES) {
|
||||||
|
cmd.add("list_camera_sizes=true")
|
||||||
|
}
|
||||||
|
if (list has ListOptions.APPS) {
|
||||||
|
cmd.add("list_apps=true")
|
||||||
|
}
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package io.github.miuzarte.scrcpyforandroid.scrcpy
|
||||||
|
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.microseconds
|
||||||
|
|
||||||
|
/*
|
||||||
|
参考官方实现尽量统一行为
|
||||||
|
*/
|
||||||
|
|
||||||
|
class Shared {
|
||||||
|
@JvmInline
|
||||||
|
value class Tick(val value: Long) {
|
||||||
|
fun toNs(): Long = value * 1000
|
||||||
|
fun toUs(): Long = value
|
||||||
|
fun toMs(): Long = value / 1000
|
||||||
|
fun toSec(): Long = value / 1_000_000
|
||||||
|
fun toSecDouble(): Double = value / 1_000_000.0
|
||||||
|
|
||||||
|
operator fun plus(other: Tick): Tick = Tick(value + other.value)
|
||||||
|
operator fun minus(other: Tick): Tick = Tick(value - other.value)
|
||||||
|
operator fun times(scale: Long): Tick = Tick(value * scale)
|
||||||
|
operator fun div(scale: Long): Tick = Tick(value / scale)
|
||||||
|
operator fun compareTo(other: Tick): Int = value.compareTo(other.value)
|
||||||
|
operator fun compareTo(other: Long): Int = value.compareTo(other)
|
||||||
|
operator fun compareTo(other: Int): Int = value.compareTo(other.toLong())
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val FREQ = 1_000_000 // 微秒/秒
|
||||||
|
fun fromNs(ns: Long): Tick = Tick(ns / 1000)
|
||||||
|
fun fromUs(us: Long): Tick = Tick(us)
|
||||||
|
fun fromMs(ms: Long): Tick = Tick(ms * 1000)
|
||||||
|
fun fromSec(sec: Long): Tick = Tick(sec * 1_000_000)
|
||||||
|
fun fromSecDouble(sec: Double): Tick = Tick((sec * 1_000_000).toLong())
|
||||||
|
fun fromDuration(duration: Duration): Tick = Tick(duration.inWholeMicroseconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toDuration(): Duration = value.microseconds
|
||||||
|
fun Duration.toTick(): Tick = Tick(this.inWholeMicroseconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
enum class ListOptions(val value: Int) {
|
||||||
|
NULL(0x0),
|
||||||
|
ENCODERS(0x1), // --list-encoders
|
||||||
|
DISPLAYS(0x2), // --list-displays
|
||||||
|
CAMERAS(0x4), // --list-cameras
|
||||||
|
CAMERA_SIZES(0x8), // --list-camera-sizes
|
||||||
|
APPS(0x10); // --list-apps
|
||||||
|
|
||||||
|
infix fun or(other: ListOptions) = value or other.value
|
||||||
|
infix fun and(other: ListOptions) = value and other.value
|
||||||
|
infix fun has(other: ListOptions) = this and other != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class LogLevel(val string: String) {
|
||||||
|
VERBOSE("verbose"),
|
||||||
|
DEBUG("debug"),
|
||||||
|
INFO("info"),
|
||||||
|
WARN("warn"),
|
||||||
|
ERROR("error");
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class Codec(val string: String) {
|
||||||
|
H264("h264"), // default, ignore when passing
|
||||||
|
H265("h265"),
|
||||||
|
AV1("av1"),
|
||||||
|
OPUS("opus"), // default, ignore when passing
|
||||||
|
AAC("aac"),
|
||||||
|
FLAC("flac"),
|
||||||
|
RAW("raw"); // wav raw
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromString(value: String): Codec {
|
||||||
|
return entries.find { it.string.equals(value, ignoreCase = true) } ?: H264
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class VideoSource(val string: String) {
|
||||||
|
DISPLAY("display"), // default, ignore when passing
|
||||||
|
CAMERA("camera");
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromString(value: String): VideoSource {
|
||||||
|
return entries.find { it.string.equals(value, ignoreCase = true) } ?: DISPLAY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class AudioSource(val string: String) {
|
||||||
|
AUTO("auto"), // OUTPUT for video DISPLAY, MIC for video CAMERA
|
||||||
|
OUTPUT("output"),
|
||||||
|
MIC("mic"),
|
||||||
|
PLAYBACK("playback"),
|
||||||
|
MIC_UNPROCESSED("mic-unprocessed"),
|
||||||
|
MIC_CAMCORDER("mic-camcorder"),
|
||||||
|
MIC_VOICE_RECOGNITION("mic-voice-recognition"),
|
||||||
|
MIC_VOICE_COMMUNICATION("mic-voice-communication"),
|
||||||
|
VOICE_CALL("voice-call"),
|
||||||
|
VOICE_CALL_UPLINK("voice-call-uplink"),
|
||||||
|
VOICE_CALL_DOWNLINK("voice-call-downlink"),
|
||||||
|
VOICE_PERFORMANCE("voice-performance");
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromString(value: String): AudioSource {
|
||||||
|
return entries.find { it.string.equals(value, ignoreCase = true) } ?: AUTO
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class CameraFacing(val string: String) {
|
||||||
|
ANY("any"), // default, ignore when passing
|
||||||
|
FRONT("front"),
|
||||||
|
BACK("back"),
|
||||||
|
EXTERNAL("external");
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromString(value: String): CameraFacing {
|
||||||
|
return entries.find { it.string.equals(value, ignoreCase = true) } ?: ANY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class Orientation(val string: String) {
|
||||||
|
ORIENT_0("0"),
|
||||||
|
ORIENT_90("90"),
|
||||||
|
ORIENT_180("180"),
|
||||||
|
ORIENT_270("270"),
|
||||||
|
FLIP_0("flip0"),
|
||||||
|
FLIP_90("flip90"),
|
||||||
|
FLIP_180("flip180"),
|
||||||
|
FLIP_270("flip270");
|
||||||
|
|
||||||
|
fun isMirror(): Boolean = ordinal and 4 != 0
|
||||||
|
fun isSwap(): Boolean = ordinal and 1 != 0
|
||||||
|
fun getRotation(): Orientation {
|
||||||
|
return when (ordinal and 3) {
|
||||||
|
0 -> ORIENT_0
|
||||||
|
1 -> ORIENT_90
|
||||||
|
2 -> ORIENT_180
|
||||||
|
3 -> ORIENT_270
|
||||||
|
else -> error("Invalid rotation value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class OrientationLock(val string: String) {
|
||||||
|
UNLOCKED("unlocked"), // ignore
|
||||||
|
LOCKED_VALUE("locked_value"), // "@${orientation.string}"
|
||||||
|
LOCKED_INITIAL("locked_initial"); // "@"
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class DisplayImePolicy(val string: String) {
|
||||||
|
UNDEFINED("undefined"), // default, ignore when passing
|
||||||
|
LOCAL("local"),
|
||||||
|
FALLBACK("fallback"),
|
||||||
|
HIDE("hide");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package io.github.miuzarte.scrcpyforandroid.services
|
package io.github.miuzarte.scrcpyforandroid.services
|
||||||
|
|
||||||
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
|
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
||||||
internal data class ConnectedDeviceInfo(
|
internal data class ConnectedDeviceInfo(
|
||||||
val model: String,
|
val model: String,
|
||||||
@@ -22,28 +24,21 @@ internal data class ConnectedDeviceInfo(
|
|||||||
* - Returns a lightweight [ConnectedDeviceInfo] structure with commonly-used properties.
|
* - Returns a lightweight [ConnectedDeviceInfo] structure with commonly-used properties.
|
||||||
*/
|
*/
|
||||||
internal fun fetchConnectedDeviceInfo(
|
internal fun fetchConnectedDeviceInfo(
|
||||||
nativeCore: NativeCoreFacade,
|
adbService: NativeAdbService,
|
||||||
host: String,
|
host: String,
|
||||||
port: Int
|
port: Int
|
||||||
): ConnectedDeviceInfo {
|
): ConnectedDeviceInfo = runBlocking {
|
||||||
fun prop(name: String): String =
|
suspend fun prop(name: String): String = runCatching {
|
||||||
runCatching { nativeCore.adbShell("getprop $name").trim() }.getOrDefault("")
|
adbService.shell("getprop $name").trim()
|
||||||
|
}.getOrDefault("")
|
||||||
|
|
||||||
val model = prop("ro.product.model")
|
ConnectedDeviceInfo(
|
||||||
val serial = prop("ro.serialno")
|
model = prop("ro.product.model").ifBlank { "$host:$port" },
|
||||||
val manufacturer = prop("ro.product.manufacturer")
|
serial = prop("ro.serialno"),
|
||||||
val brand = prop("ro.product.brand")
|
manufacturer = prop("ro.product.manufacturer"),
|
||||||
val device = prop("ro.product.device")
|
brand = prop("ro.product.brand"),
|
||||||
val androidRelease = prop("ro.build.version.release")
|
device = prop("ro.product.device"),
|
||||||
val sdkInt = prop("ro.build.version.sdk").toIntOrNull() ?: -1
|
androidRelease = prop("ro.build.version.release"),
|
||||||
|
sdkInt = prop("ro.build.version.sdk").toIntOrNull() ?: -1,
|
||||||
return ConnectedDeviceInfo(
|
|
||||||
model = model.ifBlank { "$host:$port" },
|
|
||||||
serial = serial,
|
|
||||||
manufacturer = manufacturer,
|
|
||||||
brand = brand,
|
|
||||||
device = device,
|
|
||||||
androidRelease = androidRelease,
|
|
||||||
sdkInt = sdkInt,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package io.github.miuzarte.scrcpyforandroid.services
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global singleton for event logging.
|
||||||
|
*
|
||||||
|
* Manages event logs with timestamp formatting and automatic log rotation.
|
||||||
|
* Logs are stored in a thread-safe SnapshotStateList for Compose integration.
|
||||||
|
*/
|
||||||
|
object EventLogger {
|
||||||
|
private const val LOG_TAG = "EventLogger"
|
||||||
|
|
||||||
|
private val _eventLog: SnapshotStateList<String> = mutableStateListOf()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only access to the event log list.
|
||||||
|
*/
|
||||||
|
val eventLog: List<String> get() = _eventLog
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log an event with timestamp and optional error.
|
||||||
|
*
|
||||||
|
* @param message The log message
|
||||||
|
* @param level Log level (Log.INFO, Log.ERROR, Log.WARN, Log.DEBUG)
|
||||||
|
* @param error Optional throwable for error logging
|
||||||
|
*/
|
||||||
|
fun logEvent(message: String, level: Int = Log.INFO, error: Throwable? = null) {
|
||||||
|
val timestamp = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date())
|
||||||
|
_eventLog.add(0, "[$timestamp] $message")
|
||||||
|
|
||||||
|
// Rotate logs if exceeds max size
|
||||||
|
if (_eventLog.size > AppDefaults.EVENT_LOG_LINES) {
|
||||||
|
_eventLog.removeRange(AppDefaults.EVENT_LOG_LINES, _eventLog.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log to Android logcat
|
||||||
|
when (level) {
|
||||||
|
Log.ERROR -> if (error != null) Log.e(LOG_TAG, message, error)
|
||||||
|
else Log.e(LOG_TAG, message)
|
||||||
|
|
||||||
|
Log.WARN -> if (error != null) Log.w(LOG_TAG, message, error)
|
||||||
|
else Log.w(LOG_TAG, message)
|
||||||
|
|
||||||
|
Log.DEBUG -> if (error != null) Log.d(LOG_TAG, message, error)
|
||||||
|
else Log.d(LOG_TAG, message)
|
||||||
|
|
||||||
|
else -> if (error != null) Log.i(LOG_TAG, message, error)
|
||||||
|
else Log.i(LOG_TAG, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all event logs.
|
||||||
|
*/
|
||||||
|
fun clearLogs() {
|
||||||
|
_eventLog.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if there are any logs.
|
||||||
|
*/
|
||||||
|
fun hasLogs(): Boolean = _eventLog.isNotEmpty()
|
||||||
|
}
|
||||||
@@ -63,13 +63,14 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
|
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
|
||||||
import io.github.miuzarte.scrcpyforandroid.ScrcpySessionInfo
|
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade.ScrcpySessionInfo
|
||||||
import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults
|
import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults
|
||||||
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.rememberAppHaptics
|
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.SuperSlide
|
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlide
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -221,7 +222,7 @@ internal fun StatusCard(
|
|||||||
internal fun PairingCard(
|
internal fun PairingCard(
|
||||||
busy: Boolean,
|
busy: Boolean,
|
||||||
autoDiscoverOnDialogOpen: Boolean,
|
autoDiscoverOnDialogOpen: Boolean,
|
||||||
onDiscoverTarget: (() -> Pair<String, Int>?)? = null,
|
onDiscoverTarget: (suspend () -> Pair<String, Int>?)? = null,
|
||||||
onPair: (host: String, port: String, code: String) -> Unit,
|
onPair: (host: String, port: String, code: String) -> Unit,
|
||||||
) {
|
) {
|
||||||
val showPairDialog = remember { mutableStateOf(false) }
|
val showPairDialog = remember { mutableStateOf(false) }
|
||||||
@@ -516,7 +517,7 @@ private fun PairingDialog(
|
|||||||
showDialog: Boolean,
|
showDialog: Boolean,
|
||||||
enabled: Boolean,
|
enabled: Boolean,
|
||||||
autoDiscoverOnDialogOpen: Boolean,
|
autoDiscoverOnDialogOpen: Boolean,
|
||||||
onDiscoverTarget: (() -> Pair<String, Int>?)?,
|
onDiscoverTarget: (suspend () -> Pair<String, Int>?)?,
|
||||||
onDismissRequest: () -> Unit,
|
onDismissRequest: () -> Unit,
|
||||||
onDismissFinished: () -> Unit,
|
onDismissFinished: () -> Unit,
|
||||||
onConfirm: (host: String, port: String, code: String) -> Unit,
|
onConfirm: (host: String, port: String, code: String) -> Unit,
|
||||||
@@ -661,6 +662,244 @@ internal fun LogsPanel(lines: List<String>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TouchEventHandler
|
||||||
|
*
|
||||||
|
* Purpose:
|
||||||
|
* - Handles touch event processing for fullscreen control screen
|
||||||
|
* - Manages pointer tracking, coordinate mapping, and touch injection
|
||||||
|
*/
|
||||||
|
class TouchEventHandler(
|
||||||
|
private val coroutineScope: CoroutineScope,
|
||||||
|
private val session: ScrcpySessionInfo,
|
||||||
|
private val touchAreaSize: IntSize,
|
||||||
|
private val activePointerIds: LinkedHashSet<Int>,
|
||||||
|
private val activePointerPositions: LinkedHashMap<Int, Offset>,
|
||||||
|
private val activePointerDevicePositions: LinkedHashMap<Int, Pair<Int, Int>>,
|
||||||
|
private val pointerLabels: LinkedHashMap<Int, Int>,
|
||||||
|
private var nextPointerLabel: Int,
|
||||||
|
private val onInjectTouch: suspend (action: Int, pointerId: Long, x: Int, y: Int, pressure: Float, buttons: Int) -> Unit,
|
||||||
|
private val onActiveTouchCountChanged: (Int) -> Unit,
|
||||||
|
private val onActiveTouchDebugChanged: (String) -> Unit,
|
||||||
|
private val onNextPointerLabelChanged: (Int) -> Unit,
|
||||||
|
) {
|
||||||
|
private val eventPointerIds = HashSet<Int>(10)
|
||||||
|
private val eventPositions = HashMap<Int, Offset>(10)
|
||||||
|
private val eventPressures = HashMap<Int, Float>(10)
|
||||||
|
private val justPressedPointerIds = HashSet<Int>(10)
|
||||||
|
|
||||||
|
fun handleMotionEvent(event: MotionEvent): Boolean {
|
||||||
|
if (touchAreaSize.width == 0 || touchAreaSize.height == 0) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
val bounds = calculateContentBounds()
|
||||||
|
|
||||||
|
if (event.actionMasked == MotionEvent.ACTION_CANCEL) {
|
||||||
|
return handleCancelAction(bounds)
|
||||||
|
}
|
||||||
|
|
||||||
|
extractEventData(event)
|
||||||
|
handleDisappearedPointers(eventPointerIds, bounds)
|
||||||
|
|
||||||
|
val endedPointerId = getEndedPointerId(event)
|
||||||
|
handlePointerDown(event, endedPointerId, bounds)
|
||||||
|
handlePointerMove(event, endedPointerId, bounds)
|
||||||
|
handlePointerUp(endedPointerId, bounds)
|
||||||
|
|
||||||
|
onActiveTouchCountChanged(activePointerIds.size)
|
||||||
|
refreshTouchDebug()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class ContentBounds(
|
||||||
|
val width: Float,
|
||||||
|
val height: Float,
|
||||||
|
val left: Float,
|
||||||
|
val top: Float,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun calculateContentBounds(): ContentBounds {
|
||||||
|
val sessionAspect = if (session.height == 0) {
|
||||||
|
16f / 9f
|
||||||
|
} else {
|
||||||
|
session.width.toFloat() / session.height.toFloat()
|
||||||
|
}
|
||||||
|
val containerWidth = touchAreaSize.width.toFloat()
|
||||||
|
val containerHeight = touchAreaSize.height.toFloat()
|
||||||
|
val containerAspect = containerWidth / containerHeight
|
||||||
|
|
||||||
|
val contentWidth: Float
|
||||||
|
val contentHeight: Float
|
||||||
|
if (sessionAspect > containerAspect) {
|
||||||
|
contentWidth = containerWidth
|
||||||
|
contentHeight = containerWidth / sessionAspect
|
||||||
|
} else {
|
||||||
|
contentHeight = containerHeight
|
||||||
|
contentWidth = containerHeight * sessionAspect
|
||||||
|
}
|
||||||
|
val contentLeft = (containerWidth - contentWidth) / 2f
|
||||||
|
val contentTop = (containerHeight - contentHeight) / 2f
|
||||||
|
|
||||||
|
return ContentBounds(contentWidth, contentHeight, contentLeft, contentTop)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isInsideContent(rawX: Float, rawY: Float, bounds: ContentBounds): Boolean {
|
||||||
|
return rawX in bounds.left..(bounds.left + bounds.width) &&
|
||||||
|
rawY in bounds.top..(bounds.top + bounds.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mapToDevice(rawX: Float, rawY: Float, bounds: ContentBounds): Pair<Int, Int> {
|
||||||
|
val normalizedX = ((rawX - bounds.left) / bounds.width).coerceIn(0f, 1f)
|
||||||
|
val normalizedY = ((rawY - bounds.top) / bounds.height).coerceIn(0f, 1f)
|
||||||
|
val x = (normalizedX * (session.width - 1).coerceAtLeast(0)).roundToInt()
|
||||||
|
.coerceIn(0, (session.width - 1).coerceAtLeast(0))
|
||||||
|
val y = (normalizedY * (session.height - 1).coerceAtLeast(0)).roundToInt()
|
||||||
|
.coerceIn(0, (session.height - 1).coerceAtLeast(0))
|
||||||
|
return x to y
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getPointerLabel(pointerId: Int): Int {
|
||||||
|
val existing = pointerLabels[pointerId]
|
||||||
|
if (existing != null) {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
val assigned = nextPointerLabel
|
||||||
|
nextPointerLabel += 1
|
||||||
|
onNextPointerLabelChanged(nextPointerLabel)
|
||||||
|
pointerLabels[pointerId] = assigned
|
||||||
|
return assigned
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshTouchDebug() {
|
||||||
|
if (activePointerIds.isEmpty()) {
|
||||||
|
onActiveTouchDebugChanged("")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val debug = activePointerIds
|
||||||
|
.sortedBy { getPointerLabel(it) }
|
||||||
|
.joinToString(separator = "\n") { pointerId ->
|
||||||
|
val label = getPointerLabel(pointerId)
|
||||||
|
val pos = activePointerDevicePositions[pointerId]
|
||||||
|
if (pos == null) {
|
||||||
|
"#$label(id=$pointerId):?"
|
||||||
|
} else {
|
||||||
|
"#$label(id=$pointerId):${pos.first},${pos.second}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onActiveTouchDebugChanged(debug)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun releasePointer(pointerId: Int, bounds: ContentBounds) {
|
||||||
|
if (!activePointerIds.contains(pointerId)) return
|
||||||
|
val pos = activePointerPositions[pointerId] ?: Offset.Zero
|
||||||
|
val (x, y) = mapToDevice(pos.x, pos.y, bounds)
|
||||||
|
coroutineScope.launch {
|
||||||
|
onInjectTouch(UiMotionActions.UP, pointerId.toLong(), x, y, 0f, 0)
|
||||||
|
}
|
||||||
|
activePointerIds -= pointerId
|
||||||
|
activePointerPositions.remove(pointerId)
|
||||||
|
activePointerDevicePositions.remove(pointerId)
|
||||||
|
pointerLabels.remove(pointerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleCancelAction(bounds: ContentBounds): Boolean {
|
||||||
|
val toCancel = activePointerIds.toList()
|
||||||
|
for (pointerId in toCancel) {
|
||||||
|
releasePointer(pointerId, bounds)
|
||||||
|
}
|
||||||
|
onActiveTouchCountChanged(activePointerIds.size)
|
||||||
|
refreshTouchDebug()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractEventData(event: MotionEvent) {
|
||||||
|
eventPointerIds.clear()
|
||||||
|
eventPositions.clear()
|
||||||
|
eventPressures.clear()
|
||||||
|
for (i in 0 until event.pointerCount) {
|
||||||
|
val pointerId = event.getPointerId(i)
|
||||||
|
eventPointerIds += pointerId
|
||||||
|
eventPositions[pointerId] = Offset(event.getX(i), event.getY(i))
|
||||||
|
eventPressures[pointerId] = event.getPressure(i).coerceIn(0f, 1f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleDisappearedPointers(eventPointerIds: Set<Int>, bounds: ContentBounds) {
|
||||||
|
val disappearedPointers = activePointerIds.filter { it !in eventPointerIds }
|
||||||
|
for (pointerId in disappearedPointers) {
|
||||||
|
releasePointer(pointerId, bounds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getEndedPointerId(event: MotionEvent): Int? {
|
||||||
|
return when (event.actionMasked) {
|
||||||
|
MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP -> event.getPointerId(event.actionIndex)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handlePointerDown(
|
||||||
|
event: MotionEvent,
|
||||||
|
endedPointerId: Int?,
|
||||||
|
bounds: ContentBounds,
|
||||||
|
) {
|
||||||
|
justPressedPointerIds.clear()
|
||||||
|
for (i in 0 until event.pointerCount) {
|
||||||
|
val pointerId = event.getPointerId(i)
|
||||||
|
if (pointerId == endedPointerId) continue
|
||||||
|
val raw = eventPositions[pointerId] ?: continue
|
||||||
|
val pressure = eventPressures[pointerId] ?: 0f
|
||||||
|
if (!activePointerIds.contains(pointerId)) {
|
||||||
|
if (!isInsideContent(raw.x, raw.y, bounds)) continue
|
||||||
|
val (x, y) = mapToDevice(raw.x, raw.y, bounds)
|
||||||
|
activePointerIds += pointerId
|
||||||
|
activePointerPositions[pointerId] = raw
|
||||||
|
activePointerDevicePositions[pointerId] = x to y
|
||||||
|
justPressedPointerIds += pointerId
|
||||||
|
coroutineScope.launch {
|
||||||
|
onInjectTouch(UiMotionActions.DOWN, pointerId.toLong(), x, y, pressure, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handlePointerMove(
|
||||||
|
event: MotionEvent,
|
||||||
|
endedPointerId: Int?,
|
||||||
|
bounds: ContentBounds,
|
||||||
|
) {
|
||||||
|
for (i in 0 until event.pointerCount) {
|
||||||
|
val pointerId = event.getPointerId(i)
|
||||||
|
if (!activePointerIds.contains(pointerId)) continue
|
||||||
|
if (pointerId == endedPointerId) continue
|
||||||
|
if (pointerId in justPressedPointerIds) continue
|
||||||
|
val raw = eventPositions[pointerId] ?: continue
|
||||||
|
val pressure = eventPressures[pointerId] ?: 0f
|
||||||
|
activePointerPositions[pointerId] = raw
|
||||||
|
val (x, y) = mapToDevice(raw.x, raw.y, bounds)
|
||||||
|
activePointerDevicePositions[pointerId] = x to y
|
||||||
|
coroutineScope.launch {
|
||||||
|
onInjectTouch(UiMotionActions.MOVE, pointerId.toLong(), x, y, pressure, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handlePointerUp(
|
||||||
|
endedPointerId: Int?,
|
||||||
|
bounds: ContentBounds,
|
||||||
|
) {
|
||||||
|
if (endedPointerId != null) {
|
||||||
|
val endPos = eventPositions[endedPointerId]
|
||||||
|
if (endPos != null) {
|
||||||
|
activePointerPositions[endedPointerId] = endPos
|
||||||
|
}
|
||||||
|
releasePointer(endedPointerId, bounds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FullscreenControlScreen
|
* FullscreenControlScreen
|
||||||
*
|
*
|
||||||
@@ -685,8 +924,10 @@ fun FullscreenControlScreen(
|
|||||||
showDebugInfo: Boolean,
|
showDebugInfo: Boolean,
|
||||||
currentFps: Float,
|
currentFps: Float,
|
||||||
enableBackHandler: Boolean = true,
|
enableBackHandler: Boolean = true,
|
||||||
onInjectTouch: (action: Int, pointerId: Long, x: Int, y: Int, pressure: Float, buttons: Int) -> Unit,
|
onInjectTouch: suspend (action: Int, pointerId: Long, x: Int, y: Int, pressure: Float, buttons: Int) -> Unit,
|
||||||
) {
|
) {
|
||||||
|
BackHandler(enabled = enableBackHandler, onBack = onDismiss)
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
var touchAreaSize by remember { mutableStateOf(IntSize.Zero) }
|
var touchAreaSize by remember { mutableStateOf(IntSize.Zero) }
|
||||||
val activePointerIds = remember { linkedSetOf<Int>() }
|
val activePointerIds = remember { linkedSetOf<Int>() }
|
||||||
val activePointerPositions = remember { linkedMapOf<Int, Offset>() }
|
val activePointerPositions = remember { linkedMapOf<Int, Offset>() }
|
||||||
@@ -695,173 +936,30 @@ fun FullscreenControlScreen(
|
|||||||
var nextPointerLabel by remember { mutableIntStateOf(1) }
|
var nextPointerLabel by remember { mutableIntStateOf(1) }
|
||||||
var activeTouchCount by remember { mutableIntStateOf(0) }
|
var activeTouchCount by remember { mutableIntStateOf(0) }
|
||||||
var activeTouchDebug by remember { mutableStateOf("") }
|
var activeTouchDebug by remember { mutableStateOf("") }
|
||||||
BackHandler(enabled = enableBackHandler, onBack = onDismiss)
|
|
||||||
|
val touchEventHandler = remember(session, touchAreaSize) {
|
||||||
|
TouchEventHandler(
|
||||||
|
coroutineScope = coroutineScope,
|
||||||
|
session = session,
|
||||||
|
touchAreaSize = touchAreaSize,
|
||||||
|
activePointerIds = activePointerIds,
|
||||||
|
activePointerPositions = activePointerPositions,
|
||||||
|
activePointerDevicePositions = activePointerDevicePositions,
|
||||||
|
pointerLabels = pointerLabels,
|
||||||
|
nextPointerLabel = nextPointerLabel,
|
||||||
|
onInjectTouch = onInjectTouch,
|
||||||
|
onActiveTouchCountChanged = { activeTouchCount = it },
|
||||||
|
onActiveTouchDebugChanged = { activeTouchDebug = it },
|
||||||
|
onNextPointerLabelChanged = { nextPointerLabel = it },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
BoxWithConstraints(
|
BoxWithConstraints(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(Color.Black)
|
.background(Color.Black)
|
||||||
.pointerInteropFilter { event ->
|
.pointerInteropFilter { event ->
|
||||||
if (touchAreaSize.width == 0 || touchAreaSize.height == 0) {
|
touchEventHandler.handleMotionEvent(event)
|
||||||
return@pointerInteropFilter true
|
|
||||||
}
|
|
||||||
|
|
||||||
val sessionAspect = if (session.height == 0) {
|
|
||||||
16f / 9f
|
|
||||||
} else {
|
|
||||||
session.width.toFloat() / session.height.toFloat()
|
|
||||||
}
|
|
||||||
val containerWidth = touchAreaSize.width.toFloat()
|
|
||||||
val containerHeight = touchAreaSize.height.toFloat()
|
|
||||||
val containerAspect = containerWidth / containerHeight
|
|
||||||
val contentWidth: Float
|
|
||||||
val contentHeight: Float
|
|
||||||
if (sessionAspect > containerAspect) {
|
|
||||||
contentWidth = containerWidth
|
|
||||||
contentHeight = containerWidth / sessionAspect
|
|
||||||
} else {
|
|
||||||
contentHeight = containerHeight
|
|
||||||
contentWidth = containerHeight * sessionAspect
|
|
||||||
}
|
|
||||||
val contentLeft = (containerWidth - contentWidth) / 2f
|
|
||||||
val contentTop = (containerHeight - contentHeight) / 2f
|
|
||||||
|
|
||||||
fun isInsideContent(rawX: Float, rawY: Float): Boolean {
|
|
||||||
return rawX in contentLeft..(contentLeft + contentWidth) &&
|
|
||||||
rawY in contentTop..(contentTop + contentHeight)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun mapToDevice(rawX: Float, rawY: Float): Pair<Int, Int> {
|
|
||||||
val normalizedX = ((rawX - contentLeft) / contentWidth).coerceIn(0f, 1f)
|
|
||||||
val normalizedY = ((rawY - contentTop) / contentHeight).coerceIn(0f, 1f)
|
|
||||||
val x = (normalizedX * (session.width - 1).coerceAtLeast(0)).roundToInt()
|
|
||||||
.coerceIn(0, (session.width - 1).coerceAtLeast(0))
|
|
||||||
val y = (normalizedY * (session.height - 1).coerceAtLeast(0)).roundToInt()
|
|
||||||
.coerceIn(0, (session.height - 1).coerceAtLeast(0))
|
|
||||||
return x to y
|
|
||||||
}
|
|
||||||
|
|
||||||
fun pointerLabel(pointerId: Int): Int {
|
|
||||||
val existing = pointerLabels[pointerId]
|
|
||||||
if (existing != null) {
|
|
||||||
return existing
|
|
||||||
}
|
|
||||||
val assigned = nextPointerLabel
|
|
||||||
nextPointerLabel += 1
|
|
||||||
pointerLabels[pointerId] = assigned
|
|
||||||
return assigned
|
|
||||||
}
|
|
||||||
|
|
||||||
fun refreshTouchDebug() {
|
|
||||||
if (activePointerIds.isEmpty()) {
|
|
||||||
activeTouchDebug = ""
|
|
||||||
return
|
|
||||||
}
|
|
||||||
activeTouchDebug = activePointerIds
|
|
||||||
.sortedBy { pointerLabel(it) }
|
|
||||||
.joinToString(separator = "\n") { pointerId ->
|
|
||||||
val label = pointerLabel(pointerId)
|
|
||||||
val pos = activePointerDevicePositions[pointerId]
|
|
||||||
if (pos == null) {
|
|
||||||
"#$label(id=$pointerId):?"
|
|
||||||
} else {
|
|
||||||
"#$label(id=$pointerId):${pos.first},${pos.second}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun releasePointer(pointerId: Int, reason: String) {
|
|
||||||
if (!activePointerIds.contains(pointerId)) return
|
|
||||||
val pos = activePointerPositions[pointerId] ?: Offset.Zero
|
|
||||||
val (x, y) = mapToDevice(pos.x, pos.y)
|
|
||||||
// val label = pointerLabel(pointerId)
|
|
||||||
// Log.d(
|
|
||||||
// FULLSCREEN_TOUCH_LOG_TAG,
|
|
||||||
// "抬起($reason): pointer#$label(id=$pointerId) x=$x y=$y"
|
|
||||||
// )
|
|
||||||
onInjectTouch(UiMotionActions.UP, pointerId.toLong(), x, y, 0f, 0)
|
|
||||||
activePointerIds -= pointerId
|
|
||||||
activePointerPositions.remove(pointerId)
|
|
||||||
activePointerDevicePositions.remove(pointerId)
|
|
||||||
pointerLabels.remove(pointerId)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.actionMasked == MotionEvent.ACTION_CANCEL) {
|
|
||||||
val toCancel = activePointerIds.toList()
|
|
||||||
for (pointerId in toCancel) {
|
|
||||||
releasePointer(pointerId, reason = "cancel")
|
|
||||||
}
|
|
||||||
activeTouchCount = activePointerIds.size
|
|
||||||
refreshTouchDebug()
|
|
||||||
return@pointerInteropFilter true
|
|
||||||
}
|
|
||||||
|
|
||||||
val eventPointerIds = HashSet<Int>(event.pointerCount)
|
|
||||||
val eventPositions = HashMap<Int, Offset>(event.pointerCount)
|
|
||||||
val eventPressures = HashMap<Int, Float>(event.pointerCount)
|
|
||||||
for (i in 0 until event.pointerCount) {
|
|
||||||
val pointerId = event.getPointerId(i)
|
|
||||||
eventPointerIds += pointerId
|
|
||||||
eventPositions[pointerId] = Offset(event.getX(i), event.getY(i))
|
|
||||||
eventPressures[pointerId] = event.getPressure(i).coerceIn(0f, 1f)
|
|
||||||
}
|
|
||||||
|
|
||||||
val disappearedPointers = activePointerIds.filter { it !in eventPointerIds }
|
|
||||||
for (pointerId in disappearedPointers) {
|
|
||||||
releasePointer(pointerId, reason = "missing")
|
|
||||||
}
|
|
||||||
|
|
||||||
val endedPointerId = when (event.actionMasked) {
|
|
||||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP -> event.getPointerId(event.actionIndex)
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
val justPressed = HashSet<Int>()
|
|
||||||
for (i in 0 until event.pointerCount) {
|
|
||||||
val pointerId = event.getPointerId(i)
|
|
||||||
if (pointerId == endedPointerId) continue
|
|
||||||
val raw = eventPositions[pointerId] ?: continue
|
|
||||||
val pressure = eventPressures[pointerId] ?: 0f
|
|
||||||
if (!activePointerIds.contains(pointerId)) {
|
|
||||||
if (!isInsideContent(raw.x, raw.y)) continue
|
|
||||||
val (x, y) = mapToDevice(raw.x, raw.y)
|
|
||||||
// val label = pointerLabel(pointerId)
|
|
||||||
// Log.d(
|
|
||||||
// FULLSCREEN_TOUCH_LOG_TAG,
|
|
||||||
// "按下: pointer#$label(id=$pointerId) x=$x y=$y"
|
|
||||||
// )
|
|
||||||
activePointerIds += pointerId
|
|
||||||
activePointerPositions[pointerId] = raw
|
|
||||||
activePointerDevicePositions[pointerId] = x to y
|
|
||||||
justPressed += pointerId
|
|
||||||
onInjectTouch(UiMotionActions.DOWN, pointerId.toLong(), x, y, pressure, 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (i in 0 until event.pointerCount) {
|
|
||||||
val pointerId = event.getPointerId(i)
|
|
||||||
if (!activePointerIds.contains(pointerId)) continue
|
|
||||||
if (pointerId == endedPointerId) continue
|
|
||||||
if (pointerId in justPressed) continue
|
|
||||||
val raw = eventPositions[pointerId] ?: continue
|
|
||||||
val pressure = eventPressures[pointerId] ?: 0f
|
|
||||||
activePointerPositions[pointerId] = raw
|
|
||||||
val (x, y) = mapToDevice(raw.x, raw.y)
|
|
||||||
activePointerDevicePositions[pointerId] = x to y
|
|
||||||
onInjectTouch(UiMotionActions.MOVE, pointerId.toLong(), x, y, pressure, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (endedPointerId != null) {
|
|
||||||
val endPos = eventPositions[endedPointerId]
|
|
||||||
if (endPos != null) {
|
|
||||||
activePointerPositions[endedPointerId] = endPos
|
|
||||||
}
|
|
||||||
releasePointer(endedPointerId, reason = "event")
|
|
||||||
}
|
|
||||||
|
|
||||||
activeTouchCount = activePointerIds.size
|
|
||||||
refreshTouchDebug()
|
|
||||||
true
|
|
||||||
}
|
}
|
||||||
.onSizeChanged { touchAreaSize = it },
|
.onSizeChanged { touchAreaSize = it },
|
||||||
) {
|
) {
|
||||||
@@ -958,6 +1056,7 @@ private fun ScrcpyVideoSurface(
|
|||||||
) {
|
) {
|
||||||
val surfaceTag = "video-main"
|
val surfaceTag = "video-main"
|
||||||
var currentSurface by remember { mutableStateOf<Surface?>(null) }
|
var currentSurface by remember { mutableStateOf<Surface?>(null) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
LaunchedEffect(session, currentSurface) {
|
LaunchedEffect(session, currentSurface) {
|
||||||
if (session != null && currentSurface != null) {
|
if (session != null && currentSurface != null) {
|
||||||
@@ -970,7 +1069,9 @@ private fun ScrcpyVideoSurface(
|
|||||||
onDispose {
|
onDispose {
|
||||||
val released = currentSurface
|
val released = currentSurface
|
||||||
if (released != null) {
|
if (released != null) {
|
||||||
nativeCore.unregisterVideoSurface(surfaceTag, released)
|
scope.launch {
|
||||||
|
nativeCore.unregisterVideoSurface(surfaceTag, released)
|
||||||
|
}
|
||||||
released.release()
|
released.release()
|
||||||
currentSurface = null
|
currentSurface = null
|
||||||
}
|
}
|
||||||
@@ -1003,7 +1104,9 @@ private fun ScrcpyVideoSurface(
|
|||||||
val released = currentSurface
|
val released = currentSurface
|
||||||
currentSurface = null
|
currentSurface = null
|
||||||
if (released != null) {
|
if (released != null) {
|
||||||
nativeCore.unregisterVideoSurface(surfaceTag, released)
|
scope.launch {
|
||||||
|
nativeCore.unregisterVideoSurface(surfaceTag, released)
|
||||||
|
}
|
||||||
released.release()
|
released.release()
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ class ReorderableList(
|
|||||||
private val showCheckbox: Boolean = false,
|
private val showCheckbox: Boolean = false,
|
||||||
private val onCheckboxChange: ((String, Boolean) -> Unit)? = null,
|
private val onCheckboxChange: ((String, Boolean) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
enum class Orientation { Column, Row }
|
enum class Orientation { Column, Row; }
|
||||||
|
|
||||||
data class Item(
|
data class Item(
|
||||||
val id: String,
|
val id: String,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
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
|
||||||
@@ -34,6 +35,7 @@ import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults
|
|||||||
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.rememberAppHaptics
|
import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import top.yukonga.miuix.kmp.basic.Button
|
import top.yukonga.miuix.kmp.basic.Button
|
||||||
import top.yukonga.miuix.kmp.basic.ButtonDefaults
|
import top.yukonga.miuix.kmp.basic.ButtonDefaults
|
||||||
import top.yukonga.miuix.kmp.basic.Icon
|
import top.yukonga.miuix.kmp.basic.Icon
|
||||||
@@ -118,7 +120,7 @@ enum class VirtualButtonAction(
|
|||||||
"截图",
|
"截图",
|
||||||
Icons.Rounded.Screenshot,
|
Icons.Rounded.Screenshot,
|
||||||
UiAndroidKeycodes.SYSRQ
|
UiAndroidKeycodes.SYSRQ
|
||||||
),
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
data class VirtualButtonItem(
|
data class VirtualButtonItem(
|
||||||
@@ -230,9 +232,10 @@ class VirtualButtonBar(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun Fullscreen(
|
fun Fullscreen(
|
||||||
onAction: (VirtualButtonAction) -> Unit,
|
onAction: suspend (VirtualButtonAction) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
val haptics = rememberAppHaptics()
|
val haptics = rememberAppHaptics()
|
||||||
var showMorePopup by remember { mutableStateOf(false) }
|
var showMorePopup by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
@@ -248,7 +251,9 @@ class VirtualButtonBar(
|
|||||||
if (action == VirtualButtonAction.MORE) {
|
if (action == VirtualButtonAction.MORE) {
|
||||||
showMorePopup = true
|
showMorePopup = true
|
||||||
} else {
|
} else {
|
||||||
onAction(action)
|
scope.launch {
|
||||||
|
onAction(action)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -284,9 +289,10 @@ class VirtualButtonBar(
|
|||||||
show: Boolean,
|
show: Boolean,
|
||||||
moreActions: List<VirtualButtonAction>,
|
moreActions: List<VirtualButtonAction>,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onAction: (VirtualButtonAction) -> Unit,
|
onAction: suspend (VirtualButtonAction) -> Unit,
|
||||||
renderInRootScaffold: Boolean,
|
renderInRootScaffold: Boolean,
|
||||||
) {
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
val haptics = rememberAppHaptics()
|
val haptics = rememberAppHaptics()
|
||||||
val spinnerItems = remember(moreActions) {
|
val spinnerItems = remember(moreActions) {
|
||||||
moreActions.map { action ->
|
moreActions.map { action ->
|
||||||
@@ -323,7 +329,9 @@ class VirtualButtonBar(
|
|||||||
dialogMode = false,
|
dialogMode = false,
|
||||||
onSelectedIndexChange = { selectedIdx ->
|
onSelectedIndexChange = { selectedIdx ->
|
||||||
haptics.confirm()
|
haptics.confirm()
|
||||||
onAction(moreActions[selectedIdx])
|
scope.launch {
|
||||||
|
onAction(moreActions[selectedIdx])
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user