wip: refactoring codebase
This commit is contained in:
@@ -1,38 +1,31 @@
|
||||
package io.github.miuzarte.scrcpyforandroid
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.view.Surface
|
||||
import androidx.core.net.toUri
|
||||
import io.github.miuzarte.scrcpyforandroid.nativecore.AnnexBDecoder
|
||||
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
|
||||
import io.github.miuzarte.scrcpyforandroid.nativecore.ScrcpyAudioPlayer
|
||||
import io.github.miuzarte.scrcpyforandroid.nativecore.ScrcpySessionManager
|
||||
import java.io.File
|
||||
import java.time.LocalTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.util.ArrayDeque
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
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
|
||||
* single-thread executor to serialize access to the native session manager
|
||||
* and decoders. Callers should use the provided methods from UI code but
|
||||
* not perform heavy work on the main thread directly.
|
||||
* Provides helpers for:
|
||||
* - ADB operations (all suspend functions)
|
||||
* - Surface/Decoder management for video rendering
|
||||
* - Control input injection (all suspend functions)
|
||||
*/
|
||||
class NativeCoreFacade(private val appContext: Context) {
|
||||
|
||||
private val adbService = NativeAdbService(appContext)
|
||||
private val sessionManager = ScrcpySessionManager(adbService)
|
||||
private val executor = Executors.newSingleThreadExecutor()
|
||||
val sessionManager = ScrcpySessionManager(NativeAdbService(appContext))
|
||||
private val sessionLifecycleMutex = Mutex()
|
||||
private val surfaceMap = ConcurrentHashMap<String, Surface>()
|
||||
private val surfaceIdentityMap = ConcurrentHashMap<String, Int>()
|
||||
private val decoderMap = ConcurrentHashMap<String, AnnexBDecoder>()
|
||||
@@ -52,11 +45,10 @@ class NativeCoreFacade(private val appContext: Context) {
|
||||
@Volatile
|
||||
private var currentSessionInfo: ScrcpySessionInfo? = null
|
||||
|
||||
fun close() {
|
||||
releaseAllDecoders()
|
||||
runCatching { sessionManager.stop() }
|
||||
runCatching { adbService.close() }
|
||||
executor.shutdown()
|
||||
suspend fun close() {
|
||||
sessionLifecycleMutex.withLock {
|
||||
releaseAllDecoders()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,29 +61,30 @@ class NativeCoreFacade(private val appContext: Context) {
|
||||
* 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.
|
||||
*/
|
||||
fun registerVideoSurface(tag: String, surface: Surface) {
|
||||
val newId = System.identityHashCode(surface)
|
||||
val oldId = surfaceIdentityMap[tag]
|
||||
if (oldId != null && oldId == newId && decoderMap.containsKey(tag)) {
|
||||
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) {
|
||||
suspend fun registerVideoSurface(tag: String, surface: Surface) {
|
||||
sessionLifecycleMutex.withLock {
|
||||
val newId = System.identityHashCode(surface)
|
||||
val oldId = surfaceIdentityMap[tag]
|
||||
if (oldId != null && oldId == newId && decoderMap.containsKey(tag)) {
|
||||
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`.
|
||||
*
|
||||
@@ -99,331 +92,22 @@ class NativeCoreFacade(private val appContext: Context) {
|
||||
* - 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.
|
||||
*/
|
||||
fun unregisterVideoSurface(tag: String, surface: Surface? = null) {
|
||||
val currentId = surfaceIdentityMap[tag]
|
||||
val requestId = surface?.let { System.identityHashCode(it) }
|
||||
if (requestId != null && currentId != null && requestId != currentId) {
|
||||
Log.i(
|
||||
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) {
|
||||
suspend fun unregisterVideoSurface(tag: String, surface: Surface? = null) {
|
||||
sessionLifecycleMutex.withLock {
|
||||
val currentId = surfaceIdentityMap[tag]
|
||||
val requestId = surface?.let { System.identityHashCode(it) }
|
||||
if (requestId != null && currentId != null && requestId != currentId) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"scrcpyStart(): create audio player codecId=0x${
|
||||
info.audioCodecId.toUInt().toString(16)
|
||||
}"
|
||||
"unregisterVideoSurface(): skip stale request tag=$tag requestSurfaceId=$requestId currentSurfaceId=$currentId"
|
||||
)
|
||||
val player = ScrcpyAudioPlayer(info.audioCodecId)
|
||||
audioPlayer = player
|
||||
sessionManager.attachAudioConsumer { packet ->
|
||||
player.feedPacket(packet.data, packet.ptsUs, packet.isConfig)
|
||||
}
|
||||
} else {
|
||||
Log.i(TAG, "scrcpyStart(): audio playback disabled for this session")
|
||||
return
|
||||
}
|
||||
|
||||
session
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
)
|
||||
Log.i(TAG, "unregisterVideoSurface(): tag=$tag surfaceId=$requestId")
|
||||
surfaceMap.remove(tag)
|
||||
surfaceIdentityMap.remove(tag)
|
||||
if (currentSessionInfo == null) {
|
||||
decoderMap.remove(tag)?.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -444,35 +128,73 @@ class NativeCoreFacade(private val appContext: Context) {
|
||||
videoFpsListeners.remove(listener)
|
||||
}
|
||||
|
||||
fun scrcpyBackOrScreenOn(action: Int = 0) {
|
||||
ioExecute {
|
||||
runCatching { sessionManager.pressBackOrScreenOn(action) }
|
||||
suspend fun scrcpyBackOrScreenOn(action: Int = 0) {
|
||||
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("/")
|
||||
val source = appContext.assets.open(clean)
|
||||
val outputFile = File(appContext.cacheDir, File(clean).name)
|
||||
source.use { input ->
|
||||
outputFile.outputStream().use { output -> input.copyTo(output) }
|
||||
/**
|
||||
* Called by Scrcpy.kt when a session stops.
|
||||
* Cleans up decoders and resets state.
|
||||
*/
|
||||
suspend fun onScrcpySessionStopped() = sessionLifecycleMutex.withLock {
|
||||
releaseAllDecoders()
|
||||
synchronized(bootstrapLock) {
|
||||
bootstrapPackets.clear()
|
||||
latestConfigPacket = null
|
||||
}
|
||||
return outputFile
|
||||
}
|
||||
|
||||
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
|
||||
currentSessionInfo = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NativeCoreFacade"
|
||||
private const val DEFAULT_SERVER_ASSET = "bin/scrcpy-server-v3.3.4"
|
||||
private const val MAX_BOOTSTRAP_PACKETS = 90
|
||||
|
||||
@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(
|
||||
@@ -692,8 +342,9 @@ class NativeCoreFacade(private val appContext: Context) {
|
||||
* - The consumer iterates [decoderMap] and feeds each decoder. Errors are
|
||||
* isolated with `runCatching` so one codec failure doesn't stop others.
|
||||
*/
|
||||
private fun ensureVideoConsumerAttached() {
|
||||
sessionManager.attachVideoConsumer { packet ->
|
||||
@Deprecated("TODO: Determine if this is really unnecessary")
|
||||
private suspend fun ensureVideoConsumerAttached(sessionMgr: ScrcpySessionManager) {
|
||||
sessionMgr.attachVideoConsumer { packet ->
|
||||
cacheBootstrapPacket(packet)
|
||||
packetCount += 1
|
||||
if (packetCount == 1L || packetCount % 120L == 0L) {
|
||||
@@ -718,7 +369,6 @@ class NativeCoreFacade(private val appContext: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun releaseAllDecoders() {
|
||||
decoderMap.values.forEach { decoder ->
|
||||
runCatching { decoder.release() }
|
||||
@@ -726,87 +376,11 @@ class NativeCoreFacade(private val appContext: Context) {
|
||||
decoderMap.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a blocking IO task on the internal single-thread executor and return its result.
|
||||
*
|
||||
* - This serializes access to native adb/session operations and unwraps
|
||||
* ExecutionException to rethrow the underlying cause.
|
||||
*/
|
||||
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 ScrcpySessionInfo(
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
val deviceName: String,
|
||||
val codec: String,
|
||||
val controlEnabled: Boolean,
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
* `InputStream`/`OutputStream` implementations and lifecycle helpers used by callers.
|
||||
*/
|
||||
internal class AdbSocketStream(
|
||||
class AdbSocketStream(
|
||||
val localId: Int,
|
||||
private val sender: (cmd: Int, arg0: Int, arg1: Int, data: ByteArray) -> Unit,
|
||||
) : Closeable {
|
||||
|
||||
@@ -143,7 +143,7 @@ internal class DirectAdbPairingClient(
|
||||
READY,
|
||||
EXCHANGING_MSGS,
|
||||
EXCHANGING_PEER_INFO,
|
||||
STOPPED,
|
||||
STOPPED;
|
||||
}
|
||||
|
||||
private lateinit var socket: Socket
|
||||
|
||||
@@ -2,18 +2,22 @@ package io.github.miuzarte.scrcpyforandroid.nativecore
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import java.nio.file.Path
|
||||
import kotlin.time.Duration
|
||||
|
||||
/**
|
||||
* 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
|
||||
* and accessed from the app's serialized IO executor.
|
||||
* Methods use Mutex for thread-safety because the underlying transport is single-connection
|
||||
* and may be accessed from multiple coroutines.
|
||||
*/
|
||||
class NativeAdbService(appContext: Context) {
|
||||
|
||||
private val transport = DirectAdbTransport(appContext)
|
||||
private val mutex = Mutex()
|
||||
|
||||
@Volatile
|
||||
private var connection: DirectAdbConnection? = null
|
||||
@@ -30,14 +34,13 @@ class NativeAdbService(appContext: Context) {
|
||||
transport.keyName = value
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun pair(host: String, port: Int, pairingCode: String): Boolean {
|
||||
suspend fun pair(host: String, port: Int, pairingCode: String): Boolean = mutex.withLock {
|
||||
val h = host.trim()
|
||||
val code = pairingCode.trim()
|
||||
require(h.isNotBlank()) { "host is blank" }
|
||||
require(code.isNotBlank()) { "pairing code is blank" }
|
||||
Log.i(TAG, "pair(): host=$h port=$port")
|
||||
return try {
|
||||
return@withLock try {
|
||||
transport.pair(h, port, code)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "pair(): failed host=$h port=$port", e)
|
||||
@@ -46,12 +49,11 @@ class NativeAdbService(appContext: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun discoverPairingService(
|
||||
suspend fun discoverPairingService(
|
||||
timeoutMs: Long = 12_000,
|
||||
includeLanDevices: Boolean = true
|
||||
): Pair<String, Int>? {
|
||||
return try {
|
||||
): Pair<String, Int>? = mutex.withLock {
|
||||
return@withLock try {
|
||||
transport.discoverPairingService(timeoutMs, includeLanDevices)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "discoverPairingService(): failed", e)
|
||||
@@ -59,12 +61,11 @@ class NativeAdbService(appContext: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun discoverConnectService(
|
||||
suspend fun discoverConnectService(
|
||||
timeoutMs: Long = 12_000,
|
||||
includeLanDevices: Boolean = true
|
||||
): Pair<String, Int>? {
|
||||
return try {
|
||||
): Pair<String, Int>? = mutex.withLock {
|
||||
return@withLock try {
|
||||
transport.discoverConnectService(timeoutMs, includeLanDevices)
|
||||
} catch (e: Exception) {
|
||||
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
|
||||
* before attempting the new connect.
|
||||
*/
|
||||
@Synchronized
|
||||
fun connect(host: String, port: Int): Boolean {
|
||||
suspend fun connect(
|
||||
host: String,
|
||||
port: Int,
|
||||
timeout: Duration = Duration.INFINITE,
|
||||
) = mutex.withLock {
|
||||
Log.i(TAG, "connect(): host=$host port=$port")
|
||||
val existing = connection
|
||||
if (existing != null && existing.isAlive() && connectedHost == host && connectedPort == port) {
|
||||
return true
|
||||
|
||||
if (connection != null
|
||||
&& connection!!.isAlive()
|
||||
&& connectedHost == host
|
||||
&& connectedPort == port
|
||||
) {
|
||||
return@withLock
|
||||
}
|
||||
disconnect()
|
||||
disconnectInternal()
|
||||
|
||||
try {
|
||||
val conn = transport.connect(host, port)
|
||||
val conn = withTimeout(timeout) { transport.connect(host, port) }
|
||||
connection = conn
|
||||
connectedHost = host
|
||||
connectedPort = port
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "connect(): failed host=$host port=$port", e)
|
||||
val detail = e.message ?: "${e.javaClass.simpleName} (no message)"
|
||||
@@ -101,39 +109,42 @@ class NativeAdbService(appContext: Context) {
|
||||
/**
|
||||
* Close the current ADB connection immediately.
|
||||
*/
|
||||
@Synchronized
|
||||
fun disconnect() {
|
||||
suspend fun disconnect() = mutex.withLock {
|
||||
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() }
|
||||
connection = null
|
||||
connectedHost = 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 {
|
||||
return connection?.takeIf { it.isAlive() }
|
||||
?: throw IllegalStateException("ADB not connected")
|
||||
|
||||
@@ -2,6 +2,10 @@ package io.github.miuzarte.scrcpyforandroid.nativecore
|
||||
|
||||
import android.util.Log
|
||||
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.BufferedReader
|
||||
import java.io.DataInputStream
|
||||
@@ -15,6 +19,8 @@ import kotlin.concurrent.thread
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class ScrcpySessionManager(private val adbService: NativeAdbService) {
|
||||
private val mutex = Mutex()
|
||||
|
||||
@Volatile
|
||||
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.
|
||||
*
|
||||
* 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
|
||||
* thread when appropriate. The facade uses an executor to serialize such calls.
|
||||
* thread when appropriate.
|
||||
*/
|
||||
@Synchronized
|
||||
fun start(serverJarPath: Path, options: ScrcpyStartOptions): SessionInfo {
|
||||
stop()
|
||||
synchronized(this) {
|
||||
serverLogBuffer.clear()
|
||||
}
|
||||
val targetPath = options.serverRemotePath.ifBlank { DEFAULT_SERVER_REMOTE_PATH }
|
||||
val scid = random.nextInt(Int.MAX_VALUE)
|
||||
val socketName = socketNameFor(scid)
|
||||
suspend fun start(
|
||||
serverJarPath: Path,
|
||||
serverCommand: String,
|
||||
scid: UInt,
|
||||
options: ClientOptions,
|
||||
): SessionInfo = mutex.withLock {
|
||||
stopInternal()
|
||||
serverLogBuffer.clear()
|
||||
val socketName = socketNameFor(scid.toInt())
|
||||
|
||||
try {
|
||||
adbService.push(serverJarPath, targetPath)
|
||||
val cmd = buildServerCommand(targetPath, scid, options)
|
||||
lastServerCommand = cmd
|
||||
lastServerCommand = serverCommand
|
||||
Log.i(
|
||||
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)
|
||||
Thread.sleep(SERVER_BOOT_DELAY_MS)
|
||||
|
||||
@@ -118,7 +122,8 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) {
|
||||
}
|
||||
|
||||
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 width: 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.
|
||||
* - Consumers should be resilient to occasional dropped packets or reader errors.
|
||||
*/
|
||||
@Synchronized
|
||||
fun attachVideoConsumer(consumer: (VideoPacket) -> Unit) {
|
||||
suspend fun attachVideoConsumer(consumer: (VideoPacket) -> Unit): Unit = mutex.withLock {
|
||||
val session = activeSession ?: throw IllegalStateException("scrcpy session not started")
|
||||
val vInput = session.videoInput ?: return
|
||||
val vStream = session.videoStream ?: return
|
||||
@@ -224,8 +228,7 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) {
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun clearVideoConsumer() {
|
||||
suspend fun clearVideoConsumer() = mutex.withLock {
|
||||
videoConsumer = null
|
||||
}
|
||||
|
||||
@@ -237,8 +240,7 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) {
|
||||
* - The function reads the audio stream header to determine whether audio is
|
||||
* available and exits early if disabled.
|
||||
*/
|
||||
@Synchronized
|
||||
fun attachAudioConsumer(consumer: (AudioPacket) -> Unit) {
|
||||
suspend fun attachAudioConsumer(consumer: (AudioPacket) -> Unit): Unit = mutex.withLock {
|
||||
val session = activeSession ?: throw IllegalStateException("scrcpy session not started")
|
||||
val aInput = session.audioInput ?: return // audio disabled or unavailable
|
||||
val aStream = session.audioStream ?: return
|
||||
@@ -307,8 +309,7 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) {
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun clearAudioConsumer() {
|
||||
suspend fun clearAudioConsumer() = mutex.withLock {
|
||||
audioConsumer = null
|
||||
}
|
||||
|
||||
@@ -316,15 +317,14 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) {
|
||||
* Inject a keycode event to the control channel.
|
||||
*
|
||||
* - Requires an active control channel; throws if absent.
|
||||
* - Synchronized to serialize control writes.
|
||||
* - Uses Mutex to serialize control writes.
|
||||
*/
|
||||
@Synchronized
|
||||
fun injectKeycode(action: Int, keycode: Int, repeat: Int = 0, metaState: Int = 0) {
|
||||
requireControlWriter().injectKeycode(action, keycode, repeat, metaState)
|
||||
}
|
||||
suspend fun injectKeycode(action: Int, keycode: Int, repeat: Int = 0, metaState: Int = 0) =
|
||||
mutex.withLock {
|
||||
requireControlWriter().injectKeycode(action, keycode, repeat, metaState)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun injectText(text: String) {
|
||||
suspend fun injectText(text: String) = mutex.withLock {
|
||||
requireControlWriter().injectText(text)
|
||||
}
|
||||
|
||||
@@ -333,10 +333,9 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) {
|
||||
*
|
||||
* - Coordinates are expected in device pixels and are written together with
|
||||
* screen dimensions so the server can interpret them correctly.
|
||||
* - Synchronized to serialize control writes.
|
||||
* - Uses Mutex to serialize control writes.
|
||||
*/
|
||||
@Synchronized
|
||||
fun injectTouch(
|
||||
suspend fun injectTouch(
|
||||
action: Int,
|
||||
pointerId: Long,
|
||||
x: Int,
|
||||
@@ -346,7 +345,7 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) {
|
||||
pressure: Float,
|
||||
actionButton: Int,
|
||||
buttons: Int,
|
||||
) {
|
||||
) = mutex.withLock {
|
||||
requireControlWriter().injectTouch(
|
||||
action,
|
||||
pointerId,
|
||||
@@ -360,8 +359,7 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) {
|
||||
)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun injectScroll(
|
||||
suspend fun injectScroll(
|
||||
x: Int,
|
||||
y: Int,
|
||||
screenWidth: Int,
|
||||
@@ -369,7 +367,7 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) {
|
||||
hScroll: Float,
|
||||
vScroll: Float,
|
||||
buttons: Int
|
||||
) {
|
||||
) = mutex.withLock {
|
||||
requireControlWriter().injectScroll(
|
||||
x,
|
||||
y,
|
||||
@@ -381,13 +379,11 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) {
|
||||
)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun pressBackOrScreenOn(action: Int = KeyEvent.ACTION_DOWN) {
|
||||
suspend fun pressBackOrScreenOn(action: Int = KeyEvent.ACTION_DOWN) = mutex.withLock {
|
||||
requireControlWriter().pressBackOrScreenOn(action)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun setDisplayPower(on: Boolean) {
|
||||
suspend fun setDisplayPower(on: Boolean) = mutex.withLock {
|
||||
requireControlWriter().setDisplayPower(on)
|
||||
}
|
||||
|
||||
@@ -397,8 +393,11 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) {
|
||||
* - Interrupts and joins reader threads with short timeouts, closes sockets,
|
||||
* and clears state. It is safe to call from any thread.
|
||||
*/
|
||||
@Synchronized
|
||||
fun stop() {
|
||||
suspend fun stop() = mutex.withLock {
|
||||
stopInternal()
|
||||
}
|
||||
|
||||
private fun stopInternal() {
|
||||
val session = activeSession ?: return
|
||||
activeSession = null
|
||||
videoConsumer = null
|
||||
@@ -430,20 +429,20 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) {
|
||||
|
||||
fun getLastServerCommand(): String? = lastServerCommand
|
||||
|
||||
@Synchronized
|
||||
fun listEncoders(serverJarPath: Path, options: ScrcpyStartOptions): EncoderLists {
|
||||
val targetPath = options.serverRemotePath.ifBlank { DEFAULT_SERVER_REMOTE_PATH }
|
||||
// TODO: 合并几个 --list-xxxx
|
||||
suspend fun listEncoders(
|
||||
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)
|
||||
|
||||
val cmd = buildServerCommand(
|
||||
targetPath = targetPath,
|
||||
scid = 0x1234abcd,
|
||||
options = options.copy(
|
||||
video = false,
|
||||
audio = false,
|
||||
control = false,
|
||||
listEncoders = true,
|
||||
cleanup = false,
|
||||
),
|
||||
serverParams = serverParams,
|
||||
serverVersion = serverVersion,
|
||||
)
|
||||
Log.i(TAG, "listEncoders(): cmd=$cmd")
|
||||
// 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)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun listCameraSizes(serverJarPath: Path, options: ScrcpyStartOptions): CameraSizeLists {
|
||||
val targetPath = options.serverRemotePath.ifBlank { DEFAULT_SERVER_REMOTE_PATH }
|
||||
suspend fun listCameraSizes(
|
||||
serverJarPath: 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)
|
||||
|
||||
val cmd = buildServerCommand(
|
||||
targetPath = targetPath,
|
||||
scid = 0x2234abcd,
|
||||
options = options.copy(
|
||||
video = false,
|
||||
audio = false,
|
||||
control = false,
|
||||
listCameraSizes = true,
|
||||
cleanup = false,
|
||||
),
|
||||
serverParams = serverParams,
|
||||
serverVersion = serverVersion,
|
||||
)
|
||||
Log.i(TAG, "listCameraSizes(): cmd=$cmd")
|
||||
val output = adbService.shell("$cmd 2>&1")
|
||||
@@ -490,288 +488,11 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) {
|
||||
|
||||
private fun buildServerCommand(
|
||||
targetPath: String,
|
||||
scid: Int,
|
||||
options: ScrcpyStartOptions
|
||||
serverParams: ServerParams,
|
||||
serverVersion: String,
|
||||
): String {
|
||||
val serverArgs = buildServerArgs(scid, options)
|
||||
return "CLASSPATH=$targetPath app_process / com.genymobile.scrcpy.Server ${options.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(",")}"
|
||||
}
|
||||
val serverArgs = serverParams.build()
|
||||
return "CLASSPATH=$targetPath app_process / com.genymobile.scrcpy.Server $serverVersion $serverArgs"
|
||||
}
|
||||
|
||||
private fun parseEncoderLists(output: String): EncoderLists {
|
||||
@@ -836,7 +557,13 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) {
|
||||
).use { reader ->
|
||||
while (true) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
if (serverLogBuffer.isEmpty()) {
|
||||
return ""
|
||||
val snapshot = synchronized(serverLogBuffer) {
|
||||
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 serverLogBuffer.toList().takeLast(take).joinToString("\n")
|
||||
return snapshot.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).
|
||||
* - Optionally expects a dummy byte on the stream to validate the server handshake.
|
||||
*/
|
||||
private fun openAbstractSocketWithRetry(
|
||||
private suspend fun openAbstractSocketWithRetry(
|
||||
socketName: String,
|
||||
expectDummyByte: Boolean
|
||||
): AdbSocketStream {
|
||||
|
||||
@@ -19,18 +19,29 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.listSaver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.compose.runtime.toMutableStateList
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
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.UiSpacing
|
||||
import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
|
||||
import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget
|
||||
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.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.EventLogger
|
||||
import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent
|
||||
import io.github.miuzarte.scrcpyforandroid.services.fetchConnectedDeviceInfo
|
||||
import io.github.miuzarte.scrcpyforandroid.services.loadDevicePageSettings
|
||||
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 java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
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_TCP_PROBE_TIMEOUT_MS = 600
|
||||
private const val DEVICE_SHORTCUT_SEPARATOR = "\u001F"
|
||||
private const val LOG_TAG = "DevicePage"
|
||||
|
||||
private val DeviceShortcutStateListSaver =
|
||||
listSaver<androidx.compose.runtime.snapshots.SnapshotStateList<DeviceShortcut>, String>(
|
||||
listSaver<SnapshotStateList<DeviceShortcut>, String>(
|
||||
save = { list ->
|
||||
list.map { item ->
|
||||
listOf(
|
||||
@@ -109,7 +116,7 @@ private val DeviceShortcutStateListSaver =
|
||||
)
|
||||
|
||||
private val StringStateListSaver =
|
||||
listSaver<androidx.compose.runtime.snapshots.SnapshotStateList<String>, String>(
|
||||
listSaver<SnapshotStateList<String>, String>(
|
||||
save = { it.toList() },
|
||||
restore = { it.toMutableStateList() },
|
||||
)
|
||||
@@ -118,15 +125,14 @@ private val StringStateListSaver =
|
||||
fun DeviceTabScreen(
|
||||
contentPadding: PaddingValues,
|
||||
nativeCore: NativeCoreFacade,
|
||||
adbService: NativeAdbService,
|
||||
scrcpy: Scrcpy,
|
||||
snack: SnackbarHostState,
|
||||
scrollBehavior: ScrollBehavior,
|
||||
virtualButtonsLayout: String,
|
||||
showPreviewVirtualButtonText: Boolean,
|
||||
previewCardHeightDp: Int,
|
||||
themeBaseIndex: Int,
|
||||
customServerUri: String?,
|
||||
serverRemotePath: String,
|
||||
onServerRemotePathChange: (String) -> Unit,
|
||||
videoCodec: String,
|
||||
onVideoCodecChange: (String) -> Unit,
|
||||
audioEnabled: Boolean,
|
||||
@@ -287,40 +293,12 @@ fun DeviceTabScreen(
|
||||
currentTargetPort
|
||||
) else null
|
||||
|
||||
val eventLog = rememberSaveable(saver = StringStateListSaver) { mutableStateListOf() }
|
||||
val quickDevices =
|
||||
rememberSaveable(saver = DeviceShortcutStateListSaver) { mutableStateListOf() }
|
||||
val sessionReconnectBlacklistHosts = remember { mutableSetOf<String>() }
|
||||
|
||||
LaunchedEffect(eventLog.size) {
|
||||
onCanClearLogsChange(eventLog.isNotEmpty())
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
LaunchedEffect(EventLogger.eventLog.size) {
|
||||
onCanClearLogsChange(EventLogger.hasLogs())
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -352,8 +330,8 @@ fun DeviceTabScreen(
|
||||
) {
|
||||
withContext(adbWorkerDispatcher) {
|
||||
// Also stops scrcpy.
|
||||
runCatching { nativeCore.scrcpyStop() }
|
||||
runCatching { nativeCore.adbDisconnect() }
|
||||
runCatching { scrcpy.stop() }
|
||||
runCatching { adbService.disconnect() }
|
||||
}
|
||||
adbConnected = false
|
||||
currentTargetHost = ""
|
||||
@@ -383,7 +361,6 @@ fun DeviceTabScreen(
|
||||
if (current.host == newHost && current.port == newPort) return
|
||||
|
||||
sessionReconnectBlacklistHosts += current.host
|
||||
logEvent("切换连接目标,先断开当前设备: ${current.host}:${current.port}")
|
||||
disconnectAdbConnection(clearQuickOnlineForTarget = current)
|
||||
}
|
||||
|
||||
@@ -420,10 +397,10 @@ fun DeviceTabScreen(
|
||||
* - 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.
|
||||
*/
|
||||
suspend fun connectWithTimeout(host: String, port: Int): Boolean {
|
||||
suspend fun connectWithTimeout(host: String, port: Int) {
|
||||
return withContext(adbWorkerDispatcher) {
|
||||
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 {
|
||||
return withContext(adbWorkerDispatcher) {
|
||||
withTimeout(ADB_KEEPALIVE_TIMEOUT_MS) {
|
||||
val connected = nativeCore.adbIsConnected()
|
||||
val connected = adbService.isConnected()
|
||||
if (!connected) {
|
||||
return@withTimeout false
|
||||
}
|
||||
runCatching {
|
||||
nativeCore.adbShell("echo -n 1")
|
||||
adbService.shell("echo -n 1")
|
||||
true
|
||||
}.getOrElse { false }
|
||||
}
|
||||
@@ -545,25 +522,21 @@ fun DeviceTabScreen(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun runAutoAdbConnect(host: String, port: Int): Boolean {
|
||||
return runCatching {
|
||||
suspend fun runAutoAdbConnect(host: String, port: Int) {
|
||||
runCatching {
|
||||
connectWithTimeout(host, port)
|
||||
}.getOrElse { error ->
|
||||
val detail = error.message?.takeIf { it.isNotBlank() } ?: error.javaClass.simpleName
|
||||
logEvent("自动重连失败: $host:$port ($detail)", Log.WARN)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshEncoderLists() {
|
||||
suspend fun refreshEncoderLists() {
|
||||
if (!adbConnected) return
|
||||
val remotePath = serverRemotePath.trim().ifBlank { AppDefaults.SERVER_REMOTE_PATH }
|
||||
runCatching {
|
||||
nativeCore.scrcpyListEncoders(
|
||||
customServerUri = customServerUri,
|
||||
remotePath = remotePath,
|
||||
)
|
||||
}.onSuccess { lists ->
|
||||
scrcpy.listOptions(list = ListOptions.ENCODERS)
|
||||
}.onSuccess { result ->
|
||||
val lists = result as Scrcpy.ListResult.Encoders
|
||||
onVideoEncoderOptionsChange(lists.videoEncoders)
|
||||
onAudioEncoderOptionsChange(lists.audioEncoders)
|
||||
onVideoEncoderTypeMapChange(lists.videoEncoderTypes)
|
||||
@@ -574,12 +547,15 @@ fun DeviceTabScreen(
|
||||
if (audioEncoder.isNotBlank() && audioEncoder !in audioEncoderOptions) {
|
||||
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()) {
|
||||
logEvent("提示: 编码器为空,请检查 server 路径/版本与设备系统日志", Log.WARN)
|
||||
EventLogger.logEvent(
|
||||
"提示: 编码器为空,请检查 server 路径/版本与设备系统日志",
|
||||
Log.WARN
|
||||
)
|
||||
val preview = lists.rawOutput.lineSequence().take(20).joinToString(" | ")
|
||||
if (preview.isNotBlank()) {
|
||||
logEvent("编码器原始输出: $preview", Log.DEBUG)
|
||||
EventLogger.logEvent("编码器原始输出: $preview", Log.DEBUG)
|
||||
}
|
||||
}
|
||||
}.onFailure { e ->
|
||||
@@ -587,41 +563,46 @@ fun DeviceTabScreen(
|
||||
onAudioEncoderOptionsChange(emptyList())
|
||||
onVideoEncoderTypeMapChange(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
|
||||
val remotePath = serverRemotePath.trim().ifBlank { AppDefaults.SERVER_REMOTE_PATH }
|
||||
runCatching {
|
||||
nativeCore.scrcpyListCameraSizes(
|
||||
customServerUri = customServerUri,
|
||||
remotePath = remotePath,
|
||||
)
|
||||
}.onSuccess { lists ->
|
||||
scrcpy.listOptions(ListOptions.CAMERA_SIZES)
|
||||
}.onSuccess { result ->
|
||||
val lists = result as Scrcpy.ListResult.CameraSizes
|
||||
onCameraSizeOptionsChange(lists.sizes)
|
||||
if (cameraSizePreset.isNotBlank() && cameraSizePreset != "custom" && cameraSizePreset !in lists.sizes) {
|
||||
onCameraSizePresetChange("")
|
||||
}
|
||||
logEvent("camera sizes 已刷新: count=${lists.sizes.size}")
|
||||
EventLogger.logEvent("camera sizes 已刷新: count=${lists.sizes.size}")
|
||||
if (lists.sizes.isEmpty()) {
|
||||
val preview = lists.rawOutput.lineSequence().take(20).joinToString(" | ")
|
||||
if (preview.isNotBlank()) {
|
||||
logEvent("camera sizes 原始输出: $preview", Log.DEBUG)
|
||||
EventLogger.logEvent("camera sizes 原始输出: $preview", Log.DEBUG)
|
||||
}
|
||||
}
|
||||
}.onFailure { e ->
|
||||
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
|
||||
currentTargetPort = port
|
||||
|
||||
val info = fetchConnectedDeviceInfo(nativeCore, host, port)
|
||||
val info = fetchConnectedDeviceInfo(adbService, host, port)
|
||||
val fullLabel = if (info.serial.isNotBlank()) {
|
||||
"${info.model} (${info.serial})"
|
||||
} else {
|
||||
@@ -761,18 +742,18 @@ fun DeviceTabScreen(
|
||||
if (alive) continue
|
||||
|
||||
logEvent("ADB 长连接中断,尝试自动重连: $host:$port", Log.WARN)
|
||||
val reconnected = runCatching { connectWithTimeout(host, port) }.getOrElse { false }
|
||||
adbConnected = reconnected
|
||||
if (reconnected) {
|
||||
try {
|
||||
connectWithTimeout(host, port)
|
||||
adbConnected = true
|
||||
statusLine = "$host:$port"
|
||||
logEvent("ADB 自动重连成功: $host:$port")
|
||||
scope.launch {
|
||||
snack.showSnackbar("ADB 自动重连成功")
|
||||
}
|
||||
} else {
|
||||
} catch (e: Exception) {
|
||||
disconnectAdbConnection()
|
||||
statusLine = "ADB 连接断开"
|
||||
logEvent("ADB 自动重连失败: $host:$port", Log.ERROR)
|
||||
logEvent("ADB 自动重连失败: $e", Log.ERROR)
|
||||
scope.launch {
|
||||
snack.showSnackbar("ADB 自动重连失败")
|
||||
}
|
||||
@@ -806,26 +787,27 @@ fun DeviceTabScreen(
|
||||
if (!portReachable) continue
|
||||
|
||||
quickConnectTriedOnce += targetKey
|
||||
val ok = runAutoAdbConnect(target.host, target.port)
|
||||
adbConnected = ok
|
||||
upsertQuickDevice(
|
||||
context,
|
||||
quickDevices,
|
||||
target.host,
|
||||
target.port,
|
||||
ok
|
||||
)
|
||||
if (ok) {
|
||||
try {
|
||||
runAutoAdbConnect(target.host, target.port)
|
||||
adbConnected = true
|
||||
upsertQuickDevice(
|
||||
context,
|
||||
quickDevices,
|
||||
target.host,
|
||||
target.port,
|
||||
true,
|
||||
)
|
||||
handleAdbConnected(target.host, target.port)
|
||||
logEvent("ADB 快速探测连接成功: ${target.host}:${target.port}")
|
||||
break
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
break
|
||||
}
|
||||
if (adbConnected) break
|
||||
}
|
||||
|
||||
val discovered = withContext(Dispatchers.IO) {
|
||||
nativeCore.adbDiscoverConnectService(
|
||||
adbService.discoverConnectService(
|
||||
timeoutMs = ADB_AUTO_RECONNECT_DISCOVER_TIMEOUT_MS,
|
||||
includeLanDevices = adbMdnsLanDiscoveryEnabled,
|
||||
)
|
||||
@@ -870,20 +852,19 @@ fun DeviceTabScreen(
|
||||
continue
|
||||
}
|
||||
|
||||
val ok = runAutoAdbConnect(discoveredHost, discoveredPort)
|
||||
adbConnected = ok
|
||||
upsertQuickDevice(
|
||||
context,
|
||||
quickDevices,
|
||||
discoveredHost,
|
||||
discoveredPort,
|
||||
ok
|
||||
)
|
||||
if (ok) {
|
||||
try {
|
||||
runAutoAdbConnect(discoveredHost, discoveredPort)
|
||||
adbConnected = true
|
||||
upsertQuickDevice(
|
||||
context,
|
||||
quickDevices,
|
||||
discoveredHost,
|
||||
discoveredPort,
|
||||
true
|
||||
)
|
||||
handleAdbConnected(discoveredHost, discoveredPort)
|
||||
logEvent("ADB 自动重连成功: $discoveredHost:$discoveredPort")
|
||||
} else {
|
||||
logEvent("ADB 自动重连失败: $discoveredHost:$discoveredPort", Log.WARN)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
|
||||
delay(ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS)
|
||||
@@ -925,7 +906,7 @@ fun DeviceTabScreen(
|
||||
runBusy("刷新 Camera Sizes") { refreshCameraSizeLists() }
|
||||
}
|
||||
onClearLogsActionChange {
|
||||
eventLog.clear()
|
||||
EventLogger.clearLogs()
|
||||
}
|
||||
onOpenReorderDevicesActionChange {
|
||||
showReorderSheet = true
|
||||
@@ -973,8 +954,8 @@ fun DeviceTabScreen(
|
||||
fun sendVirtualButtonAction(action: VirtualButtonAction) {
|
||||
val keycode = action.keycode ?: return
|
||||
runBusy("发送 ${action.title}") {
|
||||
nativeCore.scrcpyInjectKeycode(0, keycode)
|
||||
nativeCore.scrcpyInjectKeycode(1, keycode)
|
||||
nativeCore.sessionManager.injectKeycode(0, keycode)
|
||||
nativeCore.sessionManager.injectKeycode(1, keycode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1024,12 +1005,13 @@ fun DeviceTabScreen(
|
||||
itemsIndexed(quickDevices, key = { _, device -> device.id }) { _, device ->
|
||||
val host = device.host
|
||||
val port = device.port
|
||||
val isConnectedTarget =
|
||||
adbConnected && currentTarget?.host == host && currentTarget.port == port
|
||||
val isConnectedTarget = adbConnected
|
||||
&& currentTarget?.host == host
|
||||
&& currentTarget.port == port
|
||||
|
||||
DeviceTile(
|
||||
device = device,
|
||||
actionText = if (isConnectedTarget) "断开" else "连接",
|
||||
actionText = if (!isConnectedTarget) "连接" else "断开",
|
||||
actionEnabled = !busy && !adbConnecting,
|
||||
actionInProgress = adbConnecting && activeDeviceActionId == device.id,
|
||||
onLongPress = { editingDeviceId = device.id },
|
||||
@@ -1040,7 +1022,24 @@ fun DeviceTabScreen(
|
||||
},
|
||||
onAction = {
|
||||
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
|
||||
runAdbConnect("断开 ADB", onFinished = { activeDeviceActionId = null }) {
|
||||
sessionReconnectBlacklistHosts += host
|
||||
@@ -1050,23 +1049,6 @@ fun DeviceTabScreen(
|
||||
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 = {
|
||||
val target = parseQuickTarget(quickConnectInput) ?: return@QuickConnectCard
|
||||
runAdbConnect("连接 ADB") {
|
||||
runAdbConnect("连接 ADB", onFinished = { activeDeviceActionId = null }) {
|
||||
disconnectCurrentTargetBeforeConnecting(target.host, target.port)
|
||||
val ok = connectWithTimeout(target.host, target.port)
|
||||
adbConnected = ok
|
||||
upsertQuickDevice(context, quickDevices, target.host, target.port, ok)
|
||||
if (ok) {
|
||||
try {
|
||||
connectWithTimeout(target.host, target.port)
|
||||
adbConnected = true
|
||||
upsertQuickDevice(context, quickDevices, target.host, target.port, true)
|
||||
handleAdbConnected(target.host, target.port)
|
||||
} else {
|
||||
} catch (e: Exception) {
|
||||
statusLine = "ADB 连接失败"
|
||||
logEvent("ADB 连接失败: ${target.host}:${target.port}", Log.ERROR)
|
||||
logEvent("ADB 连接失败: $e", Log.ERROR)
|
||||
scope.launch {
|
||||
snack.showSnackbar("ADB 连接失败")
|
||||
}
|
||||
@@ -1116,7 +1098,7 @@ fun DeviceTabScreen(
|
||||
busy = busy,
|
||||
autoDiscoverOnDialogOpen = adbPairingAutoDiscoverOnDialogOpen,
|
||||
onDiscoverTarget = {
|
||||
nativeCore.adbDiscoverPairingService(
|
||||
adbService.discoverPairingService(
|
||||
includeLanDevices = adbMdnsLanDiscoveryEnabled,
|
||||
)
|
||||
},
|
||||
@@ -1125,7 +1107,7 @@ fun DeviceTabScreen(
|
||||
val resolvedHost = host.trim()
|
||||
val resolvedPort = port.toIntOrNull() ?: return@runBusy
|
||||
val resolvedCode = code.trim()
|
||||
val ok = nativeCore.adbPair(
|
||||
val ok = adbService.pair(
|
||||
resolvedHost,
|
||||
resolvedPort,
|
||||
resolvedCode,
|
||||
@@ -1222,52 +1204,55 @@ fun DeviceTabScreen(
|
||||
cropY.filter(Char::isDigit),
|
||||
)
|
||||
val effectiveTurnScreenOff = turnScreenOff && !noControl
|
||||
val session = nativeCore.scrcpyStart(
|
||||
NativeCoreFacade.defaultStartRequest(
|
||||
customServerUri = customServerUri,
|
||||
maxSize = maxSize,
|
||||
maxFps = maxFps,
|
||||
videoBitRate = bitRateBps,
|
||||
remotePath = serverRemotePath.trim(),
|
||||
videoCodec = videoCodec,
|
||||
audio = audioEnabled,
|
||||
audioCodec = audioCodec,
|
||||
audioBitRate = audioBitRateBps,
|
||||
noControl = noControl,
|
||||
videoEncoder = videoEncoder,
|
||||
videoCodecOptions = videoCodecOptions,
|
||||
audioEncoder = audioEncoder,
|
||||
audioCodecOptions = audioCodecOptions,
|
||||
audioDup = audioDup,
|
||||
audioSource = resolvedAudioSource,
|
||||
videoSource = resolvedVideoSource,
|
||||
cameraId = resolvedCameraId,
|
||||
cameraFacing = resolvedCameraFacing,
|
||||
cameraSize = resolvedCameraSize,
|
||||
cameraAr = resolvedCameraAr,
|
||||
cameraFps = resolvedCameraFps,
|
||||
cameraHighSpeed = cameraHighSpeed,
|
||||
noAudioPlayback = noAudioPlayback,
|
||||
noVideo = noVideo,
|
||||
requireAudio = requireAudio,
|
||||
turnScreenOff = effectiveTurnScreenOff,
|
||||
newDisplay = newDisplayArg,
|
||||
displayId = displayId,
|
||||
crop = crop,
|
||||
),
|
||||
|
||||
val options = ClientOptions(
|
||||
crop = crop,
|
||||
videoCodecOptions = videoCodecOptions,
|
||||
audioCodecOptions = audioCodecOptions,
|
||||
videoEncoder = videoEncoder,
|
||||
audioEncoder = audioEncoder,
|
||||
cameraId = resolvedCameraId,
|
||||
cameraSize = resolvedCameraSize,
|
||||
cameraAr = resolvedCameraAr,
|
||||
cameraFps = resolvedCameraFps.toUShort(),
|
||||
videoCodec = Codec.fromString(videoCodec),
|
||||
audioCodec = Codec.fromString(audioCodec),
|
||||
videoSource = if (resolvedVideoSource == "camera") VideoSource.CAMERA else VideoSource.DISPLAY,
|
||||
audioSource = if (resolvedAudioSource.isNotBlank()) AudioSource.fromString(
|
||||
resolvedAudioSource
|
||||
) else AudioSource.AUTO,
|
||||
cameraFacing = if (resolvedCameraFacing.isNotBlank()) CameraFacing.fromString(
|
||||
resolvedCameraFacing
|
||||
) else CameraFacing.ANY,
|
||||
maxSize = maxSize.toUShort(),
|
||||
videoBitRate = bitRateBps.toUInt(),
|
||||
audioBitRate = audioBitRateBps.toUInt(),
|
||||
maxFps = if (maxFps > 0f) maxFps.toString() else "",
|
||||
displayId = (displayId ?: 0).toUInt(),
|
||||
control = !noControl,
|
||||
video = !noVideo,
|
||||
audio = audioEnabled,
|
||||
requireAudio = requireAudio,
|
||||
audioPlayback = !noAudioPlayback,
|
||||
turnScreenOff = effectiveTurnScreenOff,
|
||||
audioDup = audioDup,
|
||||
newDisplay = newDisplayArg,
|
||||
cameraHighSpeed = cameraHighSpeed,
|
||||
)
|
||||
|
||||
// Start scrcpy using Scrcpy class
|
||||
val session = scrcpy.start(
|
||||
options = options,
|
||||
)
|
||||
|
||||
sessionInfo = session
|
||||
statusLine = "scrcpy 运行中"
|
||||
@SuppressLint("DefaultLocale")
|
||||
val videoDetail = if (noVideo) {
|
||||
"off"
|
||||
} else {
|
||||
"${session.codec} ${session.width}x${session.height} @${
|
||||
String.format(
|
||||
"%.1f",
|
||||
bitRateMbps
|
||||
)
|
||||
}Mbps"
|
||||
"${session.codec} ${session.width}x${session.height} " +
|
||||
"@${String.format("%.1f", bitRateMbps)}Mbps"
|
||||
}
|
||||
val audioDetail = if (!audioEnabled) {
|
||||
"off"
|
||||
@@ -1279,17 +1264,16 @@ fun DeviceTabScreen(
|
||||
scope.launch {
|
||||
snack.showSnackbar("scrcpy 已启动")
|
||||
}
|
||||
nativeCore.getLastScrcpyServerCommand()?.let { command ->
|
||||
scrcpy.getLastServerCommand()?.let { command ->
|
||||
logEvent("scrcpy-server args: $command")
|
||||
}
|
||||
}
|
||||
},
|
||||
onStop = {
|
||||
runBusy("停止 scrcpy") {
|
||||
nativeCore.scrcpyStop()
|
||||
scrcpy.stop()
|
||||
sessionInfo = null
|
||||
statusLine =
|
||||
currentTarget?.let { "${it.host}:${it.port}" } ?: "ADB 已连接"
|
||||
statusLine = "${currentTarget!!.host}:${currentTarget.port}"
|
||||
logEvent("scrcpy 已停止")
|
||||
scope.launch {
|
||||
snack.showSnackbar("scrcpy 已停止")
|
||||
@@ -1333,9 +1317,9 @@ fun DeviceTabScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (eventLog.isNotEmpty()) item {
|
||||
if (EventLogger.hasLogs()) item {
|
||||
Spacer(Modifier.height(UiSpacing.PageItem))
|
||||
LogsPanel(lines = eventLog)
|
||||
LogsPanel(lines = EventLogger.eventLog)
|
||||
}
|
||||
|
||||
// TODO: 放进 [AppPageLazyColumn] 里
|
||||
|
||||
@@ -20,7 +20,7 @@ import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
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.widgets.FullscreenControlScreen
|
||||
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
|
||||
@@ -113,9 +113,9 @@ fun FullscreenControlPage(
|
||||
}
|
||||
}
|
||||
|
||||
fun sendKeycode(keycode: Int) {
|
||||
nativeCore.scrcpyInjectKeycode(0, keycode)
|
||||
nativeCore.scrcpyInjectKeycode(1, keycode)
|
||||
suspend fun sendKeycode(keycode: Int) {
|
||||
nativeCore.sessionManager.injectKeycode(0, keycode)
|
||||
nativeCore.sessionManager.injectKeycode(1, keycode)
|
||||
}
|
||||
|
||||
Scaffold(contentWindowInsets = WindowInsets(0, 0, 0, 0)) { contentPadding ->
|
||||
@@ -132,7 +132,7 @@ fun FullscreenControlPage(
|
||||
currentFps = currentFps,
|
||||
enableBackHandler = false,
|
||||
onInjectTouch = { action, pointerId, x, y, pressure, buttons ->
|
||||
nativeCore.scrcpyInjectTouch(
|
||||
nativeCore.sessionManager.injectTouch(
|
||||
action = action,
|
||||
pointerId = pointerId,
|
||||
x = x,
|
||||
@@ -150,7 +150,9 @@ fun FullscreenControlPage(
|
||||
bar.Fullscreen(
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
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.UiMotion
|
||||
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.loadDevicePageSettings
|
||||
import io.github.miuzarte.scrcpyforandroid.services.loadMainSettings
|
||||
@@ -82,7 +84,7 @@ private enum class MainTabDestination(
|
||||
val icon: ImageVector,
|
||||
) {
|
||||
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 {
|
||||
@@ -99,7 +101,11 @@ fun MainPage() {
|
||||
val initialOrientation = remember(activity) {
|
||||
activity?.requestedOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||
}
|
||||
|
||||
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 initialDeviceSettings = remember(context) { loadDevicePageSettings(context) }
|
||||
val snackHostState = remember { SnackbarHostState() }
|
||||
@@ -272,7 +278,7 @@ fun MainPage() {
|
||||
}
|
||||
|
||||
LaunchedEffect(adbKeyName) {
|
||||
nativeCore.setAdbKeyName(adbKeyName.ifBlank { AppDefaults.ADB_KEY_NAME })
|
||||
adbService.keyName = adbKeyName.ifBlank { AppDefaults.ADB_KEY_NAME }
|
||||
}
|
||||
|
||||
fun popRoot() {
|
||||
@@ -308,8 +314,8 @@ fun MainPage() {
|
||||
lastExitBackPressAtMs = 0L
|
||||
scope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching { nativeCore.scrcpyStop() }
|
||||
runCatching { nativeCore.adbDisconnect() }
|
||||
runCatching { scrcpy.stop() }
|
||||
runCatching { adbService.disconnect() }
|
||||
}
|
||||
activity?.finish()
|
||||
}
|
||||
@@ -422,15 +428,14 @@ fun MainPage() {
|
||||
DeviceTabScreen(
|
||||
contentPadding = pagePadding,
|
||||
nativeCore = nativeCore,
|
||||
adbService = adbService,
|
||||
scrcpy = scrcpy,
|
||||
snack = snackHostState,
|
||||
scrollBehavior = deviceScrollBehavior,
|
||||
virtualButtonsLayout = virtualButtonsLayout,
|
||||
showPreviewVirtualButtonText = showPreviewVirtualButtonText,
|
||||
previewCardHeightDp = devicePreviewCardHeightDp,
|
||||
themeBaseIndex = themeBaseIndex,
|
||||
customServerUri = customServerUri,
|
||||
serverRemotePath = serverRemotePath,
|
||||
onServerRemotePathChange = { serverRemotePath = it },
|
||||
videoCodec = videoCodec,
|
||||
onVideoCodecChange = { videoCodec = it },
|
||||
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
|
||||
|
||||
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
|
||||
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
internal data class ConnectedDeviceInfo(
|
||||
val model: String,
|
||||
@@ -22,28 +24,21 @@ internal data class ConnectedDeviceInfo(
|
||||
* - Returns a lightweight [ConnectedDeviceInfo] structure with commonly-used properties.
|
||||
*/
|
||||
internal fun fetchConnectedDeviceInfo(
|
||||
nativeCore: NativeCoreFacade,
|
||||
adbService: NativeAdbService,
|
||||
host: String,
|
||||
port: Int
|
||||
): ConnectedDeviceInfo {
|
||||
fun prop(name: String): String =
|
||||
runCatching { nativeCore.adbShell("getprop $name").trim() }.getOrDefault("")
|
||||
): ConnectedDeviceInfo = runBlocking {
|
||||
suspend fun prop(name: String): String = runCatching {
|
||||
adbService.shell("getprop $name").trim()
|
||||
}.getOrDefault("")
|
||||
|
||||
val model = prop("ro.product.model")
|
||||
val serial = prop("ro.serialno")
|
||||
val manufacturer = prop("ro.product.manufacturer")
|
||||
val brand = prop("ro.product.brand")
|
||||
val device = prop("ro.product.device")
|
||||
val androidRelease = prop("ro.build.version.release")
|
||||
val 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,
|
||||
ConnectedDeviceInfo(
|
||||
model = prop("ro.product.model").ifBlank { "$host:$port" },
|
||||
serial = prop("ro.serialno"),
|
||||
manufacturer = prop("ro.product.manufacturer"),
|
||||
brand = prop("ro.product.brand"),
|
||||
device = prop("ro.product.device"),
|
||||
androidRelease = prop("ro.build.version.release"),
|
||||
sdkInt = prop("ro.build.version.sdk").toIntOrNull() ?: -1,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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.viewinterop.AndroidView
|
||||
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.ScrcpyPresets
|
||||
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
|
||||
import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
|
||||
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut
|
||||
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlide
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -221,7 +222,7 @@ internal fun StatusCard(
|
||||
internal fun PairingCard(
|
||||
busy: Boolean,
|
||||
autoDiscoverOnDialogOpen: Boolean,
|
||||
onDiscoverTarget: (() -> Pair<String, Int>?)? = null,
|
||||
onDiscoverTarget: (suspend () -> Pair<String, Int>?)? = null,
|
||||
onPair: (host: String, port: String, code: String) -> Unit,
|
||||
) {
|
||||
val showPairDialog = remember { mutableStateOf(false) }
|
||||
@@ -516,7 +517,7 @@ private fun PairingDialog(
|
||||
showDialog: Boolean,
|
||||
enabled: Boolean,
|
||||
autoDiscoverOnDialogOpen: Boolean,
|
||||
onDiscoverTarget: (() -> Pair<String, Int>?)?,
|
||||
onDiscoverTarget: (suspend () -> Pair<String, Int>?)?,
|
||||
onDismissRequest: () -> Unit,
|
||||
onDismissFinished: () -> 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
|
||||
*
|
||||
@@ -685,8 +924,10 @@ fun FullscreenControlScreen(
|
||||
showDebugInfo: Boolean,
|
||||
currentFps: Float,
|
||||
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) }
|
||||
val activePointerIds = remember { linkedSetOf<Int>() }
|
||||
val activePointerPositions = remember { linkedMapOf<Int, Offset>() }
|
||||
@@ -695,173 +936,30 @@ fun FullscreenControlScreen(
|
||||
var nextPointerLabel by remember { mutableIntStateOf(1) }
|
||||
var activeTouchCount by remember { mutableIntStateOf(0) }
|
||||
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(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
.pointerInteropFilter { event ->
|
||||
if (touchAreaSize.width == 0 || touchAreaSize.height == 0) {
|
||||
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
|
||||
touchEventHandler.handleMotionEvent(event)
|
||||
}
|
||||
.onSizeChanged { touchAreaSize = it },
|
||||
) {
|
||||
@@ -958,6 +1056,7 @@ private fun ScrcpyVideoSurface(
|
||||
) {
|
||||
val surfaceTag = "video-main"
|
||||
var currentSurface by remember { mutableStateOf<Surface?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(session, currentSurface) {
|
||||
if (session != null && currentSurface != null) {
|
||||
@@ -970,7 +1069,9 @@ private fun ScrcpyVideoSurface(
|
||||
onDispose {
|
||||
val released = currentSurface
|
||||
if (released != null) {
|
||||
nativeCore.unregisterVideoSurface(surfaceTag, released)
|
||||
scope.launch {
|
||||
nativeCore.unregisterVideoSurface(surfaceTag, released)
|
||||
}
|
||||
released.release()
|
||||
currentSurface = null
|
||||
}
|
||||
@@ -1003,7 +1104,9 @@ private fun ScrcpyVideoSurface(
|
||||
val released = currentSurface
|
||||
currentSurface = null
|
||||
if (released != null) {
|
||||
nativeCore.unregisterVideoSurface(surfaceTag, released)
|
||||
scope.launch {
|
||||
nativeCore.unregisterVideoSurface(surfaceTag, released)
|
||||
}
|
||||
released.release()
|
||||
}
|
||||
return true
|
||||
|
||||
@@ -37,7 +37,7 @@ class ReorderableList(
|
||||
private val showCheckbox: Boolean = false,
|
||||
private val onCheckboxChange: ((String, Boolean) -> Unit)? = null,
|
||||
) {
|
||||
enum class Orientation { Column, Row }
|
||||
enum class Orientation { Column, Row; }
|
||||
|
||||
data class Item(
|
||||
val id: String,
|
||||
|
||||
@@ -25,6 +25,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
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.UiSpacing
|
||||
import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
|
||||
import kotlinx.coroutines.launch
|
||||
import top.yukonga.miuix.kmp.basic.Button
|
||||
import top.yukonga.miuix.kmp.basic.ButtonDefaults
|
||||
import top.yukonga.miuix.kmp.basic.Icon
|
||||
@@ -118,7 +120,7 @@ enum class VirtualButtonAction(
|
||||
"截图",
|
||||
Icons.Rounded.Screenshot,
|
||||
UiAndroidKeycodes.SYSRQ
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
data class VirtualButtonItem(
|
||||
@@ -230,9 +232,10 @@ class VirtualButtonBar(
|
||||
|
||||
@Composable
|
||||
fun Fullscreen(
|
||||
onAction: (VirtualButtonAction) -> Unit,
|
||||
onAction: suspend (VirtualButtonAction) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val haptics = rememberAppHaptics()
|
||||
var showMorePopup by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -248,7 +251,9 @@ class VirtualButtonBar(
|
||||
if (action == VirtualButtonAction.MORE) {
|
||||
showMorePopup = true
|
||||
} else {
|
||||
onAction(action)
|
||||
scope.launch {
|
||||
onAction(action)
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -284,9 +289,10 @@ class VirtualButtonBar(
|
||||
show: Boolean,
|
||||
moreActions: List<VirtualButtonAction>,
|
||||
onDismiss: () -> Unit,
|
||||
onAction: (VirtualButtonAction) -> Unit,
|
||||
onAction: suspend (VirtualButtonAction) -> Unit,
|
||||
renderInRootScaffold: Boolean,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val haptics = rememberAppHaptics()
|
||||
val spinnerItems = remember(moreActions) {
|
||||
moreActions.map { action ->
|
||||
@@ -323,7 +329,9 @@ class VirtualButtonBar(
|
||||
dialogMode = false,
|
||||
onSelectedIndexChange = { selectedIdx ->
|
||||
haptics.confirm()
|
||||
onAction(moreActions[selectedIdx])
|
||||
scope.launch {
|
||||
onAction(moreActions[selectedIdx])
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user