diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 57dc91d..d448197 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -98,6 +98,7 @@ dependencies { implementation("org.conscrypt:conscrypt-android:2.5.2") implementation("sh.calvin.reorderable:reorderable:3.0.0") implementation("androidx.datastore:datastore-preferences:1.2.1") + implementation(libs.androidx.compose.runtime) testImplementation(libs.junit) androidTestImplementation(platform(libs.androidx.compose.bom)) diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/MainActivity.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/MainActivity.kt index 39cbeab..48b2a58 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/MainActivity.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/MainActivity.kt @@ -5,7 +5,9 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import io.github.miuzarte.scrcpyforandroid.pages.MainPage +import io.github.miuzarte.scrcpyforandroid.storage.PreferenceMigration import io.github.miuzarte.scrcpyforandroid.storage.Storage +import kotlinx.coroutines.runBlocking class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -14,6 +16,12 @@ class MainActivity : ComponentActivity() { // initialize settings singleton Storage.init(applicationContext) + val migration = PreferenceMigration(applicationContext) + runBlocking { + if (migration.needsMigration()) + migration.migrate(clearSharedPrefs = true) + } + enableEdgeToEdge() setContent { diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt index 8842f36..27d671b 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt @@ -6,9 +6,7 @@ import android.os.Looper import android.util.Log import android.view.Surface 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 io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import java.util.ArrayDeque @@ -16,15 +14,17 @@ import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CopyOnWriteArraySet /** - * Facade that centralizes video rendering and ADB operations. + * Facade that centralizes video rendering. * * Provides helpers for: - * - ADB operations (all suspend functions) * - Surface/Decoder management for video rendering - * - Control input injection (all suspend functions) + * - Video size and FPS monitoring */ -class NativeCoreFacade(private val appContext: Context) { - val sessionManager = ScrcpySessionManager(NativeAdbService(appContext)) +class NativeCoreFacade private constructor() { + @Volatile + var session: Scrcpy.Session? = null + private set + private val sessionLifecycleMutex = Mutex() private val surfaceMap = ConcurrentHashMap() private val surfaceIdentityMap = ConcurrentHashMap() @@ -40,10 +40,7 @@ class NativeCoreFacade(private val appContext: Context) { private var packetCount: Long = 0 @Volatile - private var audioPlayer: ScrcpyAudioPlayer? = null - - @Volatile - private var currentSessionInfo: ScrcpySessionInfo? = null + private var currentSessionInfo: Scrcpy.Session.SessionInfo? = null suspend fun close() { sessionLifecycleMutex.withLock { @@ -63,6 +60,10 @@ class NativeCoreFacade(private val appContext: Context) { */ suspend fun registerVideoSurface(tag: String, surface: Surface) { sessionLifecycleMutex.withLock { + if (!surface.isValid) { + Log.w(TAG, "registerVideoSurface(): skip invalid surface for tag=$tag") + return + } val newId = System.identityHashCode(surface) val oldId = surfaceIdentityMap[tag] if (oldId != null && oldId == newId && decoderMap.containsKey(tag)) { @@ -103,12 +104,12 @@ class NativeCoreFacade(private val appContext: Context) { ) return } - Log.i(TAG, "unregisterVideoSurface(): tag=$tag surfaceId=$requestId") + Log.i(TAG, "unregisterVideoSurface(): tag=$tag surfaceId=$requestId, releasing decoder") surfaceMap.remove(tag) surfaceIdentityMap.remove(tag) - if (currentSessionInfo == null) { - decoderMap.remove(tag)?.release() - } + // Always release decoder when surface is unregistered + // This ensures clean state when surface is recreated + decoderMap.remove(tag)?.release() } } @@ -129,7 +130,7 @@ class NativeCoreFacade(private val appContext: Context) { } suspend fun scrcpyBackOrScreenOn(action: Int = 0) { - sessionManager.pressBackOrScreenOn(action) + session?.pressBackOrScreenOn(action) } /** @@ -137,9 +138,10 @@ class NativeCoreFacade(private val appContext: Context) { * Sets up video decoders for registered surfaces. */ suspend fun onScrcpySessionStarted( - session: ScrcpySessionInfo, - sessionMgr: ScrcpySessionManager + session: Scrcpy.Session.SessionInfo, + sessionMgr: Scrcpy.Session ) = sessionLifecycleMutex.withLock { + this.session = sessionMgr currentSessionInfo = session releaseAllDecoders() synchronized(bootstrapLock) { @@ -148,6 +150,10 @@ class NativeCoreFacade(private val appContext: Context) { } surfaceMap.forEach { (tag, surface) -> + if (!surface.isValid) { + Log.w(TAG, "onScrcpySessionStarted(): skip invalid surface for tag=$tag") + return@forEach + } Log.i(TAG, "onScrcpySessionStarted(): bind decoder to tag=$tag") createOrReplaceDecoder(tag, surface, session) } @@ -164,10 +170,16 @@ class NativeCoreFacade(private val appContext: Context) { "videoFeed(): packets=$packetCount key=${packet.isKeyFrame} cfg=${packet.isConfig} decoders=${decoderMap.size}" ) } - decoderMap.forEach { (tag, decoder) -> + // Snapshot decoders to avoid feeding released decoders during iteration + val decoders = decoderMap.toMap() + decoders.forEach { (tag, decoder) -> if (!surfaceIdentityMap.containsKey(tag)) { return@forEach } + // Double-check decoder is still in map before feeding + if (decoderMap[tag] != decoder) { + return@forEach + } runCatching { decoder.feedAnnexB( packet.data, @@ -185,6 +197,7 @@ class NativeCoreFacade(private val appContext: Context) { * Cleans up decoders and resets state. */ suspend fun onScrcpySessionStopped() = sessionLifecycleMutex.withLock { + session = null releaseAllDecoders() synchronized(bootstrapLock) { bootstrapPackets.clear() @@ -202,7 +215,7 @@ class NativeCoreFacade(private val appContext: Context) { fun get(context: Context): NativeCoreFacade { return instance ?: synchronized(this) { - instance ?: NativeCoreFacade(context.applicationContext).also { instance = it } + instance ?: NativeCoreFacade().also { instance = it } } } @@ -246,23 +259,26 @@ class NativeCoreFacade(private val appContext: Context) { * - Newly created decoders are fed with any cached bootstrap packets to allow * faster playback startup. */ - private fun createOrReplaceDecoder(tag: String, surface: Surface, session: ScrcpySessionInfo) { - decoderMap.remove(tag)?.release() - val mime = when (session.codec.lowercase()) { - "h264" -> "video/avc" - "h265" -> "video/hevc" - "av1" -> "video/av01" - else -> "video/avc" + private fun createOrReplaceDecoder(tag: String, surface: Surface, session: Scrcpy.Session.SessionInfo) { + if (!surface.isValid) { + Log.w(TAG, "createOrReplaceDecoder(): skip invalid surface for tag=$tag") + return } + decoderMap.remove(tag)?.release() Log.i( TAG, - "createOrReplaceDecoder(): tag=$tag codec=$mime size=${session.width}x${session.height}" + "createOrReplaceDecoder(): tag=$tag codec=${session.codecName} size=${session.width}x${session.height}" ) val decoder = AnnexBDecoder( width = session.width, height = session.height, outputSurface = surface, - mimeType = mime, + mimeType = when (session.codecName.lowercase()) { + "h264" -> "video/avc" + "h265" -> "video/hevc" + "av1" -> "video/av01" + else -> "video/avc" + }, onOutputSizeChanged = { width, height -> val current = currentSessionInfo if (current == null || (current.width == width && current.height == height)) { @@ -304,7 +320,7 @@ class NativeCoreFacade(private val appContext: Context) { } } - private fun cacheBootstrapPacket(packet: ScrcpySessionManager.VideoPacket) { + private fun cacheBootstrapPacket(packet: Scrcpy.Session.VideoPacket) { val cached = CachedPacket( data = packet.data.copyOf(), ptsUs = packet.ptsUs, @@ -343,7 +359,7 @@ class NativeCoreFacade(private val appContext: Context) { * isolated with `runCatching` so one codec failure doesn't stop others. */ @Deprecated("TODO: Determine if this is really unnecessary") - private suspend fun ensureVideoConsumerAttached(sessionMgr: ScrcpySessionManager) { + private suspend fun ensureVideoConsumerAttached(sessionMgr: Scrcpy.Session) { sessionMgr.attachVideoConsumer { packet -> cacheBootstrapPacket(packet) packetCount += 1 @@ -353,10 +369,16 @@ class NativeCoreFacade(private val appContext: Context) { "videoFeed(): packets=$packetCount key=${packet.isKeyFrame} cfg=${packet.isConfig} decoders=${decoderMap.size}" ) } - decoderMap.forEach { (tag, decoder) -> + // Snapshot decoders to avoid feeding released decoders during iteration + val decoders = decoderMap.toMap() + decoders.forEach { (tag, decoder) -> if (!surfaceIdentityMap.containsKey(tag)) { return@forEach } + // Double-check decoder is still in map before feeding + if (decoderMap[tag] != decoder) { + return@forEach + } runCatching { decoder.feedAnnexB( packet.data, @@ -375,12 +397,4 @@ class NativeCoreFacade(private val appContext: Context) { } decoderMap.clear() } - - data class ScrcpySessionInfo( - val width: Int, - val height: Int, - val deviceName: String, - val codec: String, - val controlEnabled: Boolean, - ) } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/ScrcpyPresets.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/ScrcpyPresets.kt index 481ab23..5e01a6a 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/ScrcpyPresets.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/ScrcpyPresets.kt @@ -1,7 +1,63 @@ package io.github.miuzarte.scrcpyforandroid.constants -object ScrcpyPresets { - val MaxSize = listOf(0, 720, 1080, 1280, 1600, 1920, 2160, 2560, 3200, 3840) // px - val MaxFPS = listOf(0, 24, 30, 45, 60, 90, 120) - val AudioBitRate = listOf(32, 64, 96, 128, 160, 192, 256, 320, 384, 512) // Kbps +/** + * A generic preset class that holds a list of preset values and provides + * methods to find the index of a value or its nearest match. + * + * @param T The type of preset values (typically Int) + * @param values The list of preset values + */ +class Preset>(val values: List) { + val lastIndex: Int get() = values.lastIndex + val size: Int get() = values.size + val indices: IntRange get() = values.indices + + operator fun get(index: Int): T = values[index] + + /** + * Find the index of the exact value or the nearest preset. + * For numeric types, finds the closest value by absolute difference. + */ + fun indexOfOrNearest(value: T): Int { + val exact = values.indexOf(value) + if (exact >= 0) return exact + + // For numeric types, find nearest by comparing + return values.withIndex().minByOrNull { (_, preset) -> + when { + preset is Number && value is Number -> { + kotlin.math.abs(preset.toDouble() - value.toDouble()) + } + else -> if (preset > value) 1.0 else -1.0 + } + }?.index ?: 0 + } +} + +/** + * Extension function for Int presets to find index from Int value. + */ +fun Preset.indexOfOrNearest(raw: Int): Int { + val exact = values.indexOf(raw) + if (exact >= 0) return exact + val nearest = values.withIndex().minByOrNull { (_, preset) -> + kotlin.math.abs(preset - raw) + } + return nearest?.index ?: 0 +} + +/** + * Extension function for Int presets to find index from String value. + */ +fun Preset.indexOfOrNearest(raw: String): Int { + if (raw.isBlank()) return 0 + val value = raw.toIntOrNull() ?: return 0 + return indexOfOrNearest(value) +} + +object ScrcpyPresets { + val MaxSize = Preset(listOf(0, 720, 1080, 1280, 1600, 1920, 2160, 2560, 3200, 3840)) // px + val MaxFPS = Preset(listOf(0, 24, 30, 45, 60, 90, 120)) // fps + val AudioBitRate = Preset(listOf(32, 64, 96, 128, 160, 192, 256, 320, 384, 512)) // Kbps + val CameraFps = Preset(listOf(0, 10, 15, 24, 30, 60, 120, 240, 480, 960)) // fps } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt index 5b390bd..d857f54 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt @@ -160,7 +160,7 @@ data class ConnectionTarget( val host: String, val port: Int = Defaults.ADB_PORT, ) { - fun marshalToString(): String = "$host:$port" + override fun toString(): String = "$host:$port" companion object { fun unmarshalFrom(s: String): ConnectionTarget? { diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/ScrcpyOptions.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/ScrcpyOptions.kt new file mode 100644 index 0000000..79db4e3 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/ScrcpyOptions.kt @@ -0,0 +1,75 @@ +package io.github.miuzarte.scrcpyforandroid.models + +class ScrcpyOptions { + data class NewDisplay(val width: Int? = null, val height: Int? = null, val dpi: Int? = null) { + override fun toString() = buildString { + if (width != null && width > 0 && height != null && height > 0) + append("${width}x${height}") + if (dpi != null && dpi > 0) + append("/$dpi") + } + + companion object { + fun parseFrom(width: String, height: String, dpi: String) = + NewDisplay( + width = width.toIntOrNull()?.takeIf { it > 0 }, + height = height.toIntOrNull()?.takeIf { it > 0 }, + dpi = dpi.toIntOrNull()?.takeIf { it > 0 }, + ) + + fun parseFrom(input: String): NewDisplay { + // [x][/] + + val trimmed = input.trim() + if (trimmed.isEmpty()) return NewDisplay() + + val slashIndex = trimmed.indexOf('/') + val sizePart = if (slashIndex >= 0) trimmed.substring(0, slashIndex) else trimmed + val dpiPart = if (slashIndex >= 0) trimmed.substring(slashIndex + 1) else "" + + val xIndex = sizePart.indexOf('x') + var widthPart = "" + var heightPart = "" + if (xIndex >= 0) { + widthPart = sizePart.substring(0, xIndex) + heightPart = sizePart.substring(xIndex + 1) + } + + return parseFrom(widthPart, heightPart, dpiPart) + } + } + } + + data class Crop( + val width: Int? = null, + val height: Int? = null, + val x: Int? = null, + val y: Int? = null, + ) { + override fun toString() = + if (width != null && width > 0 + && height != null && height > 0 + && x != null && x > 0 + && y != null && y > 0 + ) "$width:$height:$x:$y" + else "" + + companion object { + fun parseFrom(width: String, height: String, x: String, y: String) = + Crop( + width = width.toIntOrNull()?.takeIf { it > 0 }, + height = height.toIntOrNull()?.takeIf { it > 0 }, + x = x.toIntOrNull()?.takeIf { it > 0 }, + y = y.toIntOrNull()?.takeIf { it > 0 }, + ) + + fun parseFrom(input: String): Crop { + // width:height:x:y + val parts = input.split(':', limit = 4) + return if (parts.size >= 4) + parseFrom(parts[0], parts[1], parts[2], parts[3]) + else Crop() + } + } + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/AnnexBDecoder.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/AnnexBDecoder.kt index 7ba5b7e..c8bbe94 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/AnnexBDecoder.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/AnnexBDecoder.kt @@ -42,6 +42,9 @@ class AnnexBDecoder( private var released = false init { + if (!outputSurface.isValid) { + throw IllegalStateException("Cannot initialize decoder: output surface is not valid") + } val format = MediaFormat.createVideoFormat(mimeType, width, height) if (sps != null) { format.setByteBuffer("csd-0", java.nio.ByteBuffer.wrap(sps)) diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbClient.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbClient.kt index 9f8e5fd..839cdf6 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbClient.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbClient.kt @@ -4,7 +4,6 @@ import android.content.Context import android.os.Build import android.util.Base64 import android.util.Log -import io.github.miuzarte.scrcpyforandroid.storage.AdbClientData import io.github.miuzarte.scrcpyforandroid.storage.AppSettings import io.github.miuzarte.scrcpyforandroid.storage.Storage import kotlinx.coroutines.runBlocking @@ -394,7 +393,12 @@ internal class DirectAdbConnection( } } - fun isAlive(): Boolean = !closed && !socket.isClosed && socket.isConnected + fun isAlive(): Boolean { + val isClosed = socket.isClosed + val isConnected = socket.isConnected + Log.d(TAG, "isClose: $isClosed, isConnected: $isConnected") + return !closed && !isClosed && isConnected + } override fun close() { if (!closed) { diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/NativeAdbService.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/NativeAdbService.kt index a3b9510..90ae92a 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/NativeAdbService.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/NativeAdbService.kt @@ -2,8 +2,10 @@ package io.github.miuzarte.scrcpyforandroid.nativecore import android.content.Context import android.util.Log +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import java.nio.file.Path import kotlin.time.Duration @@ -14,6 +16,8 @@ import kotlin.time.Duration * * Methods use Mutex for thread-safety because the underlying transport is single-connection * and may be accessed from multiple coroutines. + * + * All network operations are executed on Dispatchers.IO. */ class NativeAdbService(appContext: Context) { private val transport = DirectAdbTransport(appContext) @@ -82,35 +86,39 @@ class NativeAdbService(appContext: Context) { host: String, port: Int, timeout: Duration = Duration.INFINITE, - ) = mutex.withLock { - Log.i(TAG, "connect(): host=$host port=$port") + ) = withContext(Dispatchers.IO) { + mutex.withLock { + Log.i(TAG, "connect(): host=$host port=$port") - if (connection != null - && connection!!.isAlive() - && connectedHost == host - && connectedPort == port - ) { - return@withLock - } - disconnectInternal() + if (connection != null + && connection!!.isAlive() + && connectedHost == host + && connectedPort == port + ) { + return@withLock + } + disconnectInternal() - try { - val conn = withTimeout(timeout) { transport.connect(host, port) } - connection = conn - connectedHost = host - connectedPort = port - } catch (e: Exception) { - Log.e(TAG, "connect(): failed host=$host port=$port", e) - val detail = e.message ?: "${e.javaClass.simpleName} (no message)" - throw IllegalStateException("ADB connect failed to $host:$port -> $detail", e) + try { + val conn = withTimeout(timeout) { transport.connect(host, port) } + connection = conn + connectedHost = host + connectedPort = port + } catch (e: Exception) { + Log.e(TAG, "connect(): failed host=$host port=$port", e) + val detail = e.message ?: "${e.javaClass.simpleName} (no message)" + throw IllegalStateException("ADB connect failed to $host:$port -> $detail", e) + } } } /** * Close the current ADB connection immediately. */ - suspend fun disconnect() = mutex.withLock { - disconnectInternal() + suspend fun disconnect() = withContext(Dispatchers.IO) { + mutex.withLock { + disconnectInternal() + } } suspend fun isConnected(): Boolean = mutex.withLock { @@ -121,7 +129,9 @@ class NativeAdbService(appContext: Context) { * Execute a shell command on the connected device and return stdout text. */ suspend fun shell(command: String): String = mutex.withLock { - requireConnection().shell(command) + val response = requireConnection().shell(command) + Log.d(TAG, "command: $command, response: $response") + response } suspend fun openShellStream(command: String): AdbSocketStream = mutex.withLock { @@ -136,7 +146,9 @@ class NativeAdbService(appContext: Context) { requireConnection().openStream("localabstract:$name") } - suspend fun close() = disconnect() + suspend fun close() { + disconnect() + } private fun disconnectInternal() { runCatching { connection?.close() } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/ScrcpySessionManager.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/ScrcpySessionManager.kt deleted file mode 100644 index b23287d..0000000 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/ScrcpySessionManager.kt +++ /dev/null @@ -1,918 +0,0 @@ -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 -import java.io.DataOutputStream -import java.io.EOFException -import java.io.InputStreamReader -import java.nio.file.Path -import java.security.SecureRandom -import java.util.ArrayDeque -import kotlin.concurrent.thread -import kotlin.math.roundToInt - -class ScrcpySessionManager(private val adbService: NativeAdbService) { - private val mutex = Mutex() - - @Volatile - private var activeSession: ActiveSession? = null - - @Volatile - private var videoConsumer: ((VideoPacket) -> Unit)? = null - - @Volatile - private var videoReaderThread: Thread? = null - - @Volatile - private var audioConsumer: ((AudioPacket) -> Unit)? = null - - @Volatile - private var audioReaderThread: Thread? = null - - @Volatile - private var lastServerCommand: String? = null - private val serverLogBuffer = ArrayDeque() - - /** - * Start a scrcpy session. - * - * Responsibilities: - * - Pushes the server artifact to the device, constructs the server command, - * and opens the server shell stream. - * - Opens the required abstract sockets (video/audio/control) with retries and - * reads initial session metadata (device name, codec, resolution). - * - Initializes an [ActiveSession] which holds socket streams and reader threads. - * - * Threading notes: - * - 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. - */ - suspend fun start( - serverJarPath: Path, - serverCommand: String, - scid: UInt, - options: ClientOptions, - ): SessionInfo = mutex.withLock { - stopInternal() - serverLogBuffer.clear() - val socketName = socketNameFor(scid.toInt()) - - try { - lastServerCommand = serverCommand - Log.i( - TAG, - "start(): socket=$socketName codec=${options.videoCodec.string} audio=${options.audio} audioCodec=${options.audioCodec.string}" - ) - val serverStream = adbService.openShellStream(serverCommand) - val serverLogThread = startServerLogThread(serverStream, socketName) - Thread.sleep(SERVER_BOOT_DELAY_MS) - - // The first socket always carries device meta (and dummy byte). - val firstStream = openAbstractSocketWithRetry(socketName, expectDummyByte = true) - val firstInput = DataInputStream(BufferedInputStream(firstStream.inputStream)) - - var videoStream: AdbSocketStream? = null - var videoInput: DataInputStream? = null - var audioStream: AdbSocketStream? = null - var audioInput: DataInputStream? = null - var controlStream: AdbSocketStream? = null - - when { - options.video -> { - videoStream = firstStream - videoInput = firstInput - } - - options.audio -> { - audioStream = firstStream - audioInput = firstInput - } - - options.control -> { - controlStream = firstStream - } - - else -> { - throw IllegalArgumentException("At least one of video/audio/control must be enabled") - } - } - - if (options.video && videoStream == null) { - val vStream = openAbstractSocketWithRetry(socketName, expectDummyByte = false) - videoStream = vStream - videoInput = DataInputStream(BufferedInputStream(vStream.inputStream)) - } - - if (options.audio && audioStream == null) { - val aStream = openAbstractSocketWithRetry(socketName, expectDummyByte = false) - audioStream = aStream - audioInput = DataInputStream(BufferedInputStream(aStream.inputStream)) - } - - if (options.control && controlStream == null) { - controlStream = openAbstractSocketWithRetry(socketName, expectDummyByte = false) - } - - val deviceName = readDeviceName(firstInput) - val audioCodecId = - if (options.audio) audioCodecIdFromName(options.audioCodec.string) else 0 - val codecId: Int - val width: Int - val height: Int - if (options.video) { - val vInput = checkNotNull(videoInput) - codecId = vInput.readInt() - width = vInput.readInt() - height = vInput.readInt() - } else { - codecId = 0 - width = 0 - height = 0 - } - - val sessionInfo = SessionInfo( - deviceName = deviceName, - codecId = codecId, - codecName = codecName(codecId), - width = width, - height = height, - audioCodecId = audioCodecId, - controlEnabled = controlStream != null, - ) - - activeSession = ActiveSession( - info = sessionInfo, - socketName = socketName, - serverStream = serverStream, - serverLogThread = serverLogThread, - videoStream = videoStream, - videoInput = videoInput, - audioStream = audioStream, - audioInput = audioInput, - controlStream = controlStream, - controlWriter = controlStream?.outputStream?.let { - ScrcpyControlWriter( - DataOutputStream(it) - ) - }, - ) - return sessionInfo - } catch (t: Throwable) { - val tail = snapshotServerLogs() - val detail = if (tail.isBlank()) "" else " | server_log_tail=\n$tail" - throw IllegalStateException("scrcpy start failed: ${t.message}$detail", t) - } - } - - /** - * Attach a video consumer callback. - * - * - Spawns a dedicated `scrcpy-video-reader` thread that reads framed Annex B - * packets from the video socket and delivers `VideoPacket` instances to [consumer]. - * - The reader thread stops when the session ends or the socket is closed. - * - Consumers should be resilient to occasional dropped packets or reader errors. - */ - 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 - videoConsumer = consumer - if (videoReaderThread?.isAlive == true) { - return - } - - videoReaderThread = thread(start = true, name = "scrcpy-video-reader") { - while (activeSession === session && !vStream.closed) { - try { - val ptsAndFlags = vInput.readLong() - val packetSize = vInput.readInt() - if (packetSize <= 0) { - continue - } - - val payload = ByteArray(packetSize) - vInput.readFully(payload) - - val config = (ptsAndFlags and PACKET_FLAG_CONFIG) != 0L - val keyFrame = (ptsAndFlags and PACKET_FLAG_KEY_FRAME) != 0L - val ptsUs = ptsAndFlags and PACKET_PTS_MASK - videoConsumer?.invoke( - VideoPacket( - data = payload, - ptsUs = ptsUs, - isConfig = config, - isKeyFrame = keyFrame, - ), - ) - } catch (_: EOFException) { - break - } catch (_: InterruptedException) { - // Ignore transient interrupts while session remains active. - if (activeSession !== session || vStream.closed) { - break - } - Thread.interrupted() - } catch (e: Exception) { - Log.w(TAG, "video reader failed", e) - break - } - } - } - } - - suspend fun clearVideoConsumer() = mutex.withLock { - videoConsumer = null - } - - /** - * Attach an audio consumer callback. - * - * - Similar to the video consumer, this starts a `scrcpy-audio-reader` thread - * which reads audio packets and dispatches `AudioPacket` to the provided callback. - * - The function reads the audio stream header to determine whether audio is - * available and exits early if disabled. - */ - 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 - audioConsumer = consumer - if (audioReaderThread?.isAlive == true) return - - audioReaderThread = thread(start = true, name = "scrcpy-audio-reader") { - val streamCodecId = try { - aInput.readInt() - } catch (e: Exception) { - Log.w(TAG, "audio codec header read failed", e) - return@thread - } - when (streamCodecId) { - AUDIO_DISABLED -> { - Log.w(TAG, "audio disabled by server") - return@thread - } - - AUDIO_ERROR -> { - Log.e(TAG, "audio stream configuration error from server") - return@thread - } - - else -> { - Log.i(TAG, "audio stream codec=0x${streamCodecId.toUInt().toString(16)}") - } - } - if (session.info.audioCodecId != 0 && streamCodecId != session.info.audioCodecId) { - Log.w( - TAG, - "audio codec mismatch: requested=0x${ - session.info.audioCodecId.toUInt().toString(16) - } stream=0x${streamCodecId.toUInt().toString(16)}", - ) - } - - while (activeSession === session && !aStream.closed) { - try { - val ptsAndFlags = aInput.readLong() - val packetSize = aInput.readInt() - if (packetSize <= 0) continue - - val payload = ByteArray(packetSize) - aInput.readFully(payload) - - val isConfig = (ptsAndFlags and PACKET_FLAG_CONFIG) != 0L - val ptsUs = ptsAndFlags and PACKET_PTS_MASK - audioConsumer?.invoke( - AudioPacket( - data = payload, - ptsUs = ptsUs, - isConfig = isConfig - ) - ) - } catch (_: EOFException) { - break - } catch (_: InterruptedException) { - if (activeSession !== session || aStream.closed) break - Thread.interrupted() - } catch (e: Exception) { - Log.w(TAG, "audio reader failed", e) - break - } - } - } - } - - suspend fun clearAudioConsumer() = mutex.withLock { - audioConsumer = null - } - - /** - * Inject a keycode event to the control channel. - * - * - Requires an active control channel; throws if absent. - * - Uses Mutex to serialize control writes. - */ - suspend fun injectKeycode(action: Int, keycode: Int, repeat: Int = 0, metaState: Int = 0) = - mutex.withLock { - requireControlWriter().injectKeycode(action, keycode, repeat, metaState) - } - - suspend fun injectText(text: String) = mutex.withLock { - requireControlWriter().injectText(text) - } - - /** - * Inject a touch event to the control channel. - * - * - Coordinates are expected in device pixels and are written together with - * screen dimensions so the server can interpret them correctly. - * - Uses Mutex to serialize control writes. - */ - suspend fun injectTouch( - action: Int, - pointerId: Long, - x: Int, - y: Int, - screenWidth: Int, - screenHeight: Int, - pressure: Float, - actionButton: Int, - buttons: Int, - ) = mutex.withLock { - requireControlWriter().injectTouch( - action, - pointerId, - x, - y, - screenWidth, - screenHeight, - pressure, - actionButton, - buttons - ) - } - - suspend fun injectScroll( - x: Int, - y: Int, - screenWidth: Int, - screenHeight: Int, - hScroll: Float, - vScroll: Float, - buttons: Int - ) = mutex.withLock { - requireControlWriter().injectScroll( - x, - y, - screenWidth, - screenHeight, - hScroll, - vScroll, - buttons - ) - } - - suspend fun pressBackOrScreenOn(action: Int = KeyEvent.ACTION_DOWN) = mutex.withLock { - requireControlWriter().pressBackOrScreenOn(action) - } - - suspend fun setDisplayPower(on: Boolean) = mutex.withLock { - requireControlWriter().setDisplayPower(on) - } - - /** - * Stop the active session and clean up reader threads and streams. - * - * - Interrupts and joins reader threads with short timeouts, closes sockets, - * and clears state. It is safe to call from any thread. - */ - suspend fun stop() = mutex.withLock { - stopInternal() - } - - private fun stopInternal() { - val session = activeSession ?: return - activeSession = null - videoConsumer = null - audioConsumer = null - - if (Thread.currentThread() !== videoReaderThread) { - runCatching { videoReaderThread?.interrupt() } - runCatching { videoReaderThread?.join(300) } - } - videoReaderThread = null - - if (Thread.currentThread() !== audioReaderThread) { - runCatching { audioReaderThread?.interrupt() } - runCatching { audioReaderThread?.join(300) } - } - audioReaderThread = null - - runCatching { session.controlStream?.close() } - runCatching { session.audioStream?.close() } - runCatching { session.videoStream?.close() } - runCatching { session.serverStream.close() } - if (Thread.currentThread() !== session.serverLogThread) { - runCatching { session.serverLogThread.interrupt() } - runCatching { session.serverLogThread.join(300) } - } - } - - fun isStarted(): Boolean = activeSession != null - - fun getLastServerCommand(): String? = lastServerCommand - - // 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, - serverParams = serverParams, - serverVersion = serverVersion, - ) - Log.i(TAG, "listEncoders(): cmd=$cmd") - // scrcpy encoder list is printed in logs, so merge stderr into stdout. - val output = adbService.shell("$cmd 2>&1") - val parsed = parseEncoderLists(output) - val preview = output.lineSequence().take(40).joinToString("\n") - Log.i( - TAG, - "listEncoders(): parsed video=${parsed.videoEncoders.size} audio=${parsed.audioEncoders.size}, outputPreview=\n$preview", - ) - return parsed.copy(rawOutput = output) - } - - 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, - serverParams = serverParams, - serverVersion = serverVersion, - ) - Log.i(TAG, "listCameraSizes(): cmd=$cmd") - val output = adbService.shell("$cmd 2>&1") - val parsed = parseCameraSizeLists(output) - val preview = output.lineSequence().take(40).joinToString("\n") - Log.i( - TAG, - "listCameraSizes(): parsed sizes=${parsed.sizes.size}, outputPreview=\n$preview", - ) - return parsed.copy(rawOutput = output) - } - - private fun requireControlWriter(): ScrcpyControlWriter { - return activeSession?.controlWriter - ?: throw IllegalStateException("scrcpy control channel not available") - } - - private fun buildServerCommand( - targetPath: String, - serverParams: ServerParams, - serverVersion: String, - ): String { - val serverArgs = serverParams.build() - return "CLASSPATH=$targetPath app_process / com.genymobile.scrcpy.Server $serverVersion $serverArgs" - } - - private fun parseEncoderLists(output: String): EncoderLists { - val video = LinkedHashSet() - val audio = LinkedHashSet() - val videoTypes = linkedMapOf() - val audioTypes = linkedMapOf() - 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 EncoderLists( - videoEncoders = video.toList(), - audioEncoders = audio.toList(), - videoEncoderTypes = videoTypes, - audioEncoderTypes = audioTypes, - ) - } - - private fun parseCameraSizeLists(output: String): CameraSizeLists { - val sizes = LinkedHashSet() - 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 CameraSizeLists(sizes = sizes.toList()) - } - - private fun startServerLogThread(serverStream: AdbSocketStream, socketName: String): Thread { - return thread(start = true, name = "scrcpy-server-log") { - try { - BufferedReader( - InputStreamReader( - serverStream.inputStream, - Charsets.UTF_8 - ) - ).use { reader -> - while (true) { - val line = reader.readLine() ?: break - // 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") - } - } - } catch (e: Exception) { - if (activeSession != null) { - Log.w(TAG, "server log thread failed", e) - } - } - } - } - - private fun snapshotServerLogs(maxLines: Int = 120): String { - val snapshot = synchronized(serverLogBuffer) { - if (serverLogBuffer.isEmpty()) { - return "" - } - val take = maxLines.coerceIn(1, SERVER_LOG_BUFFER_MAX_LINES) - serverLogBuffer.toList().takeLast(take) - } - return snapshot.joinToString("\n") - } - - /** - * Open an abstract adb socket with retry. - * - * - 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 suspend fun openAbstractSocketWithRetry( - socketName: String, - expectDummyByte: Boolean - ): AdbSocketStream { - var lastEx: Exception? = null - repeat(CONNECT_RETRY_COUNT) { attempt -> - try { - val stream = adbService.openAbstractSocket(socketName) - if (expectDummyByte) { - val value = stream.inputStream.read() - if (value < 0) { - stream.close() - throw EOFException("scrcpy dummy byte missing") - } - } - return stream - } catch (e: Exception) { - lastEx = e - if (attempt < CONNECT_RETRY_COUNT - 1) Thread.sleep(CONNECT_RETRY_DELAY_MS) - } - } - throw IllegalStateException("Unable to open scrcpy socket '$socketName'", lastEx) - } - - private fun readDeviceName(input: DataInputStream): String { - val buffer = ByteArray(DEVICE_NAME_FIELD_LENGTH) - input.readFully(buffer) - val firstZero = buffer.indexOf(0) - val length = if (firstZero >= 0) firstZero else buffer.size - return buffer.copyOf(length).toString(Charsets.UTF_8) - } - - private fun codecName(codecId: Int): String { - return when (codecId) { - VIDEO_CODEC_H264 -> "h264" - VIDEO_CODEC_H265 -> "h265" - VIDEO_CODEC_AV1 -> "av1" - else -> "unknown" - } - } - - private fun audioCodecIdFromName(name: String): Int { - return when (name.lowercase()) { - "opus" -> AUDIO_CODEC_OPUS - "aac" -> AUDIO_CODEC_AAC - "raw" -> AUDIO_CODEC_RAW - "flac" -> AUDIO_CODEC_FLAC - else -> 0 - } - } - - data class SessionInfo( - val deviceName: String, - val codecId: Int, - val codecName: String, - val width: Int, - val height: Int, - val audioCodecId: Int = 0, - val controlEnabled: Boolean, - ) - - data class VideoPacket( - val data: ByteArray, - val ptsUs: Long, - val isConfig: Boolean, - val isKeyFrame: Boolean, - ) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as VideoPacket - - if (ptsUs != other.ptsUs) return false - if (isConfig != other.isConfig) return false - if (isKeyFrame != other.isKeyFrame) return false - if (!data.contentEquals(other.data)) return false - - return true - } - - override fun hashCode(): Int { - var result = ptsUs.hashCode() - result = 31 * result + isConfig.hashCode() - result = 31 * result + isKeyFrame.hashCode() - result = 31 * result + data.contentHashCode() - return result - } - } - - data class AudioPacket( - val data: ByteArray, - val ptsUs: Long, - val isConfig: Boolean, - ) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as AudioPacket - - if (ptsUs != other.ptsUs) return false - if (isConfig != other.isConfig) return false - if (!data.contentEquals(other.data)) return false - - return true - } - - override fun hashCode(): Int { - var result = ptsUs.hashCode() - result = 31 * result + isConfig.hashCode() - result = 31 * result + data.contentHashCode() - return result - } - } - - data class ScrcpyStartOptions( - val serverVersion: String = "3.3.4", - val serverRemotePath: String = DEFAULT_SERVER_REMOTE_PATH, - val video: Boolean = true, - val audio: Boolean = true, - val control: Boolean = true, - val cleanup: Boolean = true, - val maxSize: Int = 0, - val maxFps: Float = 0f, - val videoBitRate: Int = 8_000_000, - val videoCodec: String = "h264", - val audioBitRate: Int = 128_000, - val audioCodec: String = "opus", - 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 newDisplay: String = "", - val displayId: Int? = null, - val crop: String = "", - val listEncoders: Boolean = false, - val listCameraSizes: Boolean = false, - ) - - data class EncoderLists( - val videoEncoders: List, - val audioEncoders: List, - val videoEncoderTypes: Map = emptyMap(), - val audioEncoderTypes: Map = emptyMap(), - val rawOutput: String = "", - ) - - data class CameraSizeLists( - val sizes: List, - val rawOutput: String = "", - ) - - private data class ActiveSession( - val info: SessionInfo, - val socketName: String, - val serverStream: AdbSocketStream, - val serverLogThread: Thread, - val videoStream: AdbSocketStream?, - val videoInput: DataInputStream?, - val audioStream: AdbSocketStream?, - val audioInput: DataInputStream?, - val controlStream: AdbSocketStream?, - val controlWriter: ScrcpyControlWriter?, - ) - - private class ScrcpyControlWriter(private val output: DataOutputStream) { - @Synchronized - fun injectKeycode(action: Int, keycode: Int, repeat: Int, metaState: Int) { - output.writeByte(TYPE_INJECT_KEYCODE) - output.writeByte(action) - output.writeInt(keycode) - output.writeInt(repeat) - output.writeInt(metaState) - output.flush() - } - - @Synchronized - fun injectText(text: String) { - val bytes = text.toByteArray(Charsets.UTF_8) - output.writeByte(TYPE_INJECT_TEXT) - output.writeInt(bytes.size) - output.write(bytes) - output.flush() - } - - @Synchronized - fun injectTouch( - action: Int, - pointerId: Long, - x: Int, - y: Int, - screenWidth: Int, - screenHeight: Int, - pressure: Float, - actionButton: Int, - buttons: Int, - ) { - output.writeByte(TYPE_INJECT_TOUCH_EVENT) - output.writeByte(action) - output.writeLong(pointerId) - writePosition(x, y, screenWidth, screenHeight) - output.writeShort(encodeUnsignedFixedPoint16(pressure)) - output.writeInt(actionButton) - output.writeInt(buttons) - output.flush() - } - - @Synchronized - fun injectScroll( - x: Int, - y: Int, - screenWidth: Int, - screenHeight: Int, - hScroll: Float, - vScroll: Float, - buttons: Int - ) { - output.writeByte(TYPE_INJECT_SCROLL_EVENT) - writePosition(x, y, screenWidth, screenHeight) - output.writeShort(encodeSignedFixedPoint16(hScroll / 16f)) - output.writeShort(encodeSignedFixedPoint16(vScroll / 16f)) - output.writeInt(buttons) - output.flush() - } - - @Synchronized - fun pressBackOrScreenOn(action: Int) { - output.writeByte(TYPE_BACK_OR_SCREEN_ON) - output.writeByte(action) - output.flush() - } - - @Synchronized - fun setDisplayPower(on: Boolean) { - output.writeByte(TYPE_SET_DISPLAY_POWER) - output.writeBoolean(on) - output.flush() - } - - private fun writePosition(x: Int, y: Int, screenWidth: Int, screenHeight: Int) { - output.writeInt(x) - output.writeInt(y) - output.writeShort(screenWidth) - output.writeShort(screenHeight) - } - - private fun encodeUnsignedFixedPoint16(value: Float): Int { - val clamped = value.coerceIn(0f, 1f) - return if (clamped >= 1f) { - 0xffff - } else { - (clamped * 65536f).roundToInt().coerceIn(0, 0xfffe) - } - } - - private fun encodeSignedFixedPoint16(value: Float): Int { - val clamped = value.coerceIn(-1f, 1f) - if (clamped >= 1f) { - return 0x7fff - } - if (clamped <= -1f) { - return -0x8000 - } - return (clamped * 32768f).roundToInt().coerceIn(-0x8000, 0x7ffe) - } - } - - companion object { - const val DEFAULT_SERVER_REMOTE_PATH = "/data/local/tmp/scrcpy-server.jar" - private const val TAG = "ScrcpySessionManager" - private const val SERVER_BOOT_DELAY_MS = 200L - private const val SERVER_LOG_BUFFER_MAX_LINES = 400 - - private const val CONNECT_RETRY_COUNT = 100 - private const val CONNECT_RETRY_DELAY_MS = 100L - private const val DEVICE_NAME_FIELD_LENGTH = 64 - private const val PACKET_FLAG_CONFIG = 1L shl 63 - private const val PACKET_FLAG_KEY_FRAME = 1L shl 62 - private const val PACKET_PTS_MASK = (1L shl 62) - 1 - private const val VIDEO_CODEC_H264 = 0x68323634 - private const val VIDEO_CODEC_H265 = 0x68323635 - private const val VIDEO_CODEC_AV1 = 0x00617631 - private const val AUDIO_CODEC_OPUS = 0x6f707573 - private const val AUDIO_CODEC_AAC = 0x00616163 - private const val AUDIO_CODEC_FLAC = 0x666c6163 - private const val AUDIO_CODEC_RAW = 0x00726177 - - // Audio stream disable codes from server (writeDisableStream) - private const val AUDIO_DISABLED = 0 - private const val AUDIO_ERROR = 1 - private const val TYPE_INJECT_KEYCODE = 0 - private const val TYPE_INJECT_TEXT = 1 - private const val TYPE_INJECT_TOUCH_EVENT = 2 - private const val TYPE_INJECT_SCROLL_EVENT = 3 - private const val TYPE_BACK_OR_SCREEN_ON = 4 - private const val TYPE_SET_DISPLAY_POWER = 10 - 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 val random = SecureRandom() - - private fun socketNameFor(scid: Int): String { - return "scrcpy_%08x".format(scid) - } - } -} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AdvancedConfigPage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AdvancedConfigPage.kt index f01cf62..0859957 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AdvancedConfigPage.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AdvancedConfigPage.kt @@ -1,5 +1,6 @@ package io.github.miuzarte.scrcpyforandroid.pages +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -11,25 +12,33 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusDirection -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import io.github.miuzarte.scrcpyforandroid.constants.ScrcpyPresets import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing +import io.github.miuzarte.scrcpyforandroid.models.ScrcpyOptions.Crop +import io.github.miuzarte.scrcpyforandroid.models.ScrcpyOptions.NewDisplay import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn -import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlide +import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlider +import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField +import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy +import io.github.miuzarte.scrcpyforandroid.scrcpy.ServerParams +import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared import io.github.miuzarte.scrcpyforandroid.storage.Storage import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import top.yukonga.miuix.kmp.basic.Card import top.yukonga.miuix.kmp.basic.ScrollBehavior import top.yukonga.miuix.kmp.basic.SnackbarHostState @@ -42,82 +51,19 @@ import top.yukonga.miuix.kmp.extra.SuperSpinner import top.yukonga.miuix.kmp.extra.SuperSwitch import kotlin.math.roundToInt -private val AUDIO_SOURCE_OPTIONS = listOf( - "output" to "output", - "playback" to "playback", - "mic" to "mic", - "mic-unprocessed" to "mic-unprocessed", - "mic-camcorder" to "mic-camcorder", - "mic-voice-recognition" to "mic-voice-recognition", - "mic-voice-communication" to "mic-voice-communication", - "voice-call" to "voice-call", - "voice-call-uplink" to "voice-call-uplink", - "voice-call-downlink" to "voice-call-downlink", - "voice-performance" to "voice-performance", - "custom" to "自定义", -) - -// TODO: Scrcpy.VideoSource -private val VIDEO_SOURCE_OPTIONS = listOf( - "display" to "display", - "camera" to "camera", -) - -private val CAMERA_FACING_OPTIONS = listOf( - "" to "默认", - "front" to "front", - "back" to "back", - "external" to "external", -) - -private val CAMERA_FPS_PRESETS = listOf(0, 10, 15, 24, 30, 60, 120, 240, 480, 960) - @Composable internal fun AdvancedConfigPage( contentPadding: PaddingValues, scrollBehavior: ScrollBehavior, snackbarHostState: SnackbarHostState, - cameraSizeOptions: SnapshotStateList, - cameraSizeDropdownItems: List, - videoEncoderDropdownItems: List, - videoEncoderTypeMap: Map, - videoEncoderIndex: Int, - audioEncoderDropdownItems: List, - audioEncoderTypeMap: Map, - audioEncoderIndex: Int, - onRefreshEncoders: () -> Unit, - onRefreshCameraSizes: () -> Unit, + scrcpy: Scrcpy, ) { val scrcpyOptions = Storage.scrcpyOptions - val context = LocalContext.current - val focusManager = LocalFocusManager.current val scope = rememberCoroutineScope() - - val videoEncoderEntries = videoEncoderDropdownItems.map { encoderName -> - if (encoderName == "默认") { - SpinnerEntry(title = encoderName) - } else { - val type = resolveEncoderTypeLabel(videoEncoderTypeMap[encoderName]) - SpinnerEntry( - title = encoderName, - summary = type.ifBlank { null }, - ) - } - } - val audioEncoderEntries = audioEncoderDropdownItems.map { encoderName -> - if (encoderName == "默认") { - SpinnerEntry(title = encoderName) - } else { - val type = resolveEncoderTypeLabel(audioEncoderTypeMap[encoderName]) - SpinnerEntry( - title = encoderName, - summary = type.ifBlank { null }, - ) - } - } + var refreshBusy by remember { mutableStateOf(false) } // TODO: handle custom value // TODO: handle empty input @@ -126,100 +72,164 @@ internal fun AdvancedConfigPage( var video by scrcpyOptions.video.asMutableState() var videoSource by scrcpyOptions.videoSource.asMutableState() - val videoSourceItems = VIDEO_SOURCE_OPTIONS.map { it.second } - val videoSourceIndex = VIDEO_SOURCE_OPTIONS.indexOfFirst { - it.first == videoSource - }.let { if (it >= 0) it else 0 } + val videoSourceItems = remember { Shared.VideoSource.entries.map { it.string } } + val videoSourceIndex = remember(videoSource) { + Shared.VideoSource.entries.indexOfFirst { it.string == videoSource }.coerceAtLeast(0) + } var displayId by scrcpyOptions.displayId.asMutableState() var cameraId by scrcpyOptions.cameraId.asMutableState() var cameraFacing by scrcpyOptions.cameraFacing.asMutableState() - val cameraFacingItems = CAMERA_FACING_OPTIONS.map { it.second } - val cameraFacingIndex = CAMERA_FACING_OPTIONS.indexOfFirst { - it.first == cameraFacing - }.let { if (it >= 0) it else 0 } - var cameraSize by scrcpyOptions.cameraSize.asMutableState() - val cameraSizeIndex = when (cameraSize) { - "custom" -> cameraSizeOptions.size + 1 - in cameraSizeOptions -> cameraSizeOptions.indexOf(cameraSize) + 1 - else -> 0 + val cameraFacingItems = remember { + listOf("默认") + Shared.CameraFacing.entries.drop(1).map { it.string } } + val cameraFacingIndex = remember(cameraFacing) { + if (cameraFacing.isEmpty()) { + 0 + } else { + val idx = Shared.CameraFacing.entries.indexOfFirst { it.string == cameraFacing } + if (idx > 0) idx else 0 + } + } + + var cameraSize by scrcpyOptions.cameraSize.asMutableState() + var cameraSizeCustom by scrcpyOptions.cameraSizeCustom.asMutableState() + var cameraSizeUseCustom by scrcpyOptions.cameraSizeUseCustom.asMutableState() + + var cameraSizeCustomInput by rememberSaveable { mutableStateOf(cameraSizeCustom) } + val cameraSizeDropdownItems = rememberSaveable(scrcpy.cameraSizes) { + listOf("自动", "自定义") + scrcpy.cameraSizes + } + var cameraSizeDropdownIndex by rememberSaveable { + mutableIntStateOf( + when { + cameraSizeUseCustom -> 1 // "自定义" + cameraSize.isEmpty() -> 0 // "自动" + cameraSize in scrcpy.cameraSizes -> scrcpy.cameraSizes.indexOf(cameraSize) + 2 + else -> 0 // 默认自动 + } + ) + } + var cameraAr by scrcpyOptions.cameraAr.asMutableState() var cameraFps by scrcpyOptions.cameraFps.asMutableState() - val cameraFpsPresetIndex = presetIndexFromInputForAdvancedPage( - cameraFps, CAMERA_FPS_PRESETS - ) + val cameraFpsPresetIndex = ScrcpyPresets.CameraFps.indexOfOrNearest(cameraFps) var cameraHighSpeed by scrcpyOptions.cameraHighSpeed.asMutableState() var audioSource by scrcpyOptions.audioSource.asMutableState() - val audioSourceItems = AUDIO_SOURCE_OPTIONS.map { it.second } - val audioSourceIndex = AUDIO_SOURCE_OPTIONS.indexOfFirst { - it.first == audioSource - }.let { if (it >= 0) it else 0 } + val audioSourceItems = remember { + Shared.AudioSource.entries.map { it.string } + } + val audioSourceIndex = remember(audioSource) { + Shared.AudioSource.entries.indexOfFirst { it.string == audioSource }.coerceAtLeast(0) + } var audioDup by scrcpyOptions.audioDup.asMutableState() var audioPlayback by scrcpyOptions.audioPlayback.asMutableState() var requireAudio by scrcpyOptions.requireAudio.asMutableState() var maxSize by scrcpyOptions.maxSize.asMutableState() - val maxSizePresetIndex = presetIndexFromInputForAdvancedPage( - maxSize.toString(), ScrcpyPresets.MaxSize - ) + val maxSizePresetIndex = ScrcpyPresets.MaxSize.indexOfOrNearest(maxSize) var maxFps by scrcpyOptions.maxFps.asMutableState() - val maxFpsPresetIndex = presetIndexFromInputForAdvancedPage( - maxFps, ScrcpyPresets.MaxFPS - ) + val maxFpsPresetIndex = ScrcpyPresets.MaxFPS.indexOfOrNearest(maxFps.toIntOrNull() ?: 0) var videoEncoder by scrcpyOptions.videoEncoder.asMutableState() var videoCodecOptions by scrcpyOptions.videoCodecOptions.asMutableState() var audioEncoder by scrcpyOptions.audioEncoder.asMutableState() var audioCodecOptions by scrcpyOptions.audioCodecOptions.asMutableState() - var newDisplayWidth by remember { - mutableStateOf("") + val videoEncoderDropdownItems = remember(scrcpy.videoEncoders) { + listOf("") + scrcpy.videoEncoders } - var newDisplayHeight by remember { - mutableStateOf("") + val videoEncoderIndex = remember(videoEncoder, scrcpy.videoEncoders) { + (scrcpy.videoEncoders.indexOf(videoEncoder) + 1).coerceAtLeast(0) } - var newDisplayDpi by remember { - mutableStateOf("") + val audioEncoderDropdownItems = remember(scrcpy.audioEncoders) { + listOf("") + scrcpy.audioEncoders } + val audioEncoderIndex = remember(audioEncoder, scrcpy.audioEncoders) { + (scrcpy.audioEncoders.indexOf(audioEncoder) + 1).coerceAtLeast(0) + } + val videoEncoderEntries = videoEncoderDropdownItems.map { encoderName -> + if (encoderName == "") { + SpinnerEntry(title = "自动") + } else { + SpinnerEntry( + title = encoderName, + summary = scrcpy.videoEncoderTypes[encoderName], + ) + } + } + val audioEncoderEntries = audioEncoderDropdownItems.map { encoderName -> + if (encoderName == "") { + SpinnerEntry(title = "自动") + } else { + SpinnerEntry( + title = encoderName, + summary = scrcpy.audioEncoderTypes[encoderName], + ) + } + } + // [x][/] - // TODO: 填充当前值到输入框 var newDisplay by scrcpyOptions.newDisplay.asMutableState() + val (width, height, dpi) = NewDisplay.parseFrom(newDisplay) + var newDisplayWidth by remember(newDisplay) { mutableStateOf(width?.toString() ?: "") } + var newDisplayHeight by remember(newDisplay) { mutableStateOf(height?.toString() ?: "") } + var newDisplayDpi by remember(newDisplay) { mutableStateOf(dpi?.toString() ?: "") } fun updateNewDisplay() { - var nd = "" - if (newDisplayWidth.isNotBlank() && newDisplayHeight.isNotBlank()) { - nd += "${newDisplayWidth}x${newDisplayHeight}" - } - if (newDisplayDpi.isNotBlank()) { - nd += "/$newDisplayDpi" - } - newDisplay = nd + newDisplay = NewDisplay + .parseFrom(newDisplayWidth, newDisplayHeight, newDisplayDpi) + .toString() } - var cropWidth by remember { - mutableStateOf("") - } - var cropHeight by remember { - mutableStateOf("") - } - var cropX by remember { - mutableStateOf("") - } - var cropY by remember { - mutableStateOf("") - } // width:height:x:y - // TODO: 填充当前值到输入框 var crop by scrcpyOptions.crop.asMutableState() - fun updateCrop(): Unit { - if (cropWidth.isNotBlank() - && cropHeight.isNotBlank() - && cropX.isNotBlank() - && cropY.isNotBlank() - ) crop = "$cropWidth:$cropHeight:$cropX:$cropY" + val (cWidth, cHeight, cX, cY) = Crop.parseFrom(crop) + var cropWidth by remember(crop) { mutableStateOf(cWidth?.toString() ?: "") } + var cropHeight by remember(crop) { mutableStateOf(cHeight?.toString() ?: "") } + var cropX by remember(crop) { mutableStateOf(cX?.toString() ?: "") } + var cropY by remember(crop) { mutableStateOf(cY?.toString() ?: "") } + fun updateCrop() { + crop = Crop + .parseFrom(cropWidth, cropHeight, cropX, cropY) + .toString() } + var serverParamsPreview by rememberSaveable { + mutableStateOf(runBlocking { + scrcpyOptions + .toClientOptions() + .toServerParams(0u) + .toList(simplify = true) + .joinToString(ServerParams.SEPARATOR) + }) + } + + // 监听所有选项变化,自动更新 serverParams 预览 + LaunchedEffect( + turnScreenOff, control, video, + videoSource, displayId, + cameraId, cameraFacing, cameraSize, cameraAr, cameraFps, cameraHighSpeed, + audioSource, audioDup, audioPlayback, requireAudio, + maxSize, maxFps, + videoEncoder, videoCodecOptions, + audioEncoder, audioCodecOptions, + newDisplay, crop, + ) { + val clientOptions = scrcpyOptions.toClientOptions() + + try { + clientOptions.validate() + } catch (e: IllegalArgumentException) { + snackbarHostState.showSnackbar("Invalid options: ${e.message}") + return@LaunchedEffect + } + + serverParamsPreview = clientOptions + .toServerParams(0u) + .toList(simplify = true) + .joinToString(ServerParams.SEPARATOR) + } // 高级参数 AppPageLazyColumn( @@ -227,6 +237,15 @@ internal fun AdvancedConfigPage( scrollBehavior = scrollBehavior, ) { item { + Card { + TextField( + value = serverParamsPreview, + onValueChange = {}, + readOnly = true, + modifier = Modifier.fillMaxWidth(), + ) + } + Card { SuperSwitch( title = "启动后关闭屏幕", @@ -265,13 +284,13 @@ internal fun AdvancedConfigPage( items = videoSourceItems, selectedIndex = videoSourceIndex, onSelectedIndexChange = { - videoSource = VIDEO_SOURCE_OPTIONS[it].first + videoSource = Shared.VideoSource.entries[it].string }, ) - if (videoSource == "display") { + AnimatedVisibility(videoSource == "display") { TextField( - value = displayId.toString(), - onValueChange = { displayId = it.toInt() }, + value = if (displayId == -1) "" else displayId.toString(), + onValueChange = { displayId = it.toIntOrNull() ?: -1 }, label = "--display-id", singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), @@ -281,7 +300,7 @@ internal fun AdvancedConfigPage( .padding(bottom = UiSpacing.CardContent), ) } - if (videoSource == "camera") { + AnimatedVisibility(videoSource == "camera") { TextField( value = cameraId, onValueChange = { cameraId = it }, @@ -292,47 +311,95 @@ internal fun AdvancedConfigPage( .padding(horizontal = UiSpacing.CardContent) .padding(bottom = UiSpacing.CardContent), ) + } + AnimatedVisibility(videoSource == "camera") { SuperArrow( title = "重新获取 Camera Sizes", summary = "--list-camera-sizes", - onClick = onRefreshCameraSizes, + onClick = { + if (refreshBusy) return@SuperArrow + scope.launch { + refreshBusy = true + try { + scrcpy.refreshCameraSizes() + snackbarHostState.showSnackbar("Camera Sizes 已刷新") + } catch (e: Exception) { + snackbarHostState.showSnackbar("刷新失败: ${e.message}") + } finally { + refreshBusy = false + } + } + }, ) + } + AnimatedVisibility(videoSource == "camera") { SuperDropdown( title = "摄像头朝向", summary = "--camera-facing", items = cameraFacingItems, selectedIndex = cameraFacingIndex, onSelectedIndexChange = { - cameraFacing = CAMERA_FACING_OPTIONS[it].first + cameraFacing = + if (it == 0) "" else Shared.CameraFacing.entries[it].string }, ) + } + AnimatedVisibility(videoSource == "camera") { SuperDropdown( title = "摄像头分辨率", summary = "--camera-size", items = cameraSizeDropdownItems, - selectedIndex = cameraSizeIndex.coerceIn( - 0, (cameraSizeDropdownItems.size - 1).coerceAtLeast(0) - ), + selectedIndex = cameraSizeDropdownIndex, onSelectedIndexChange = { - cameraSize = when (it) { - 0 -> "" - cameraSizeDropdownItems.lastIndex -> "custom" - else -> cameraSizeDropdownItems[it] + cameraSizeDropdownIndex = it + cameraSizeUseCustom = it == 1 + when (it) { + 0 -> { + // "自动" + cameraSize = "" + cameraSizeCustomInput = "" + } + + 1 -> { + // "自定义" - 进入自定义输入模式 + cameraSizeCustomInput = cameraSize.takeIf { size -> + size.isNotEmpty() && size !in scrcpy.cameraSizes + } ?: "" + } + + else -> { + // 选择列表中的实际分辨率 + cameraSize = cameraSizeDropdownItems[it] + cameraSizeCustomInput = "" + } } }, ) - if (cameraSize == "custom") { - TextField( - value = cameraSize, - onValueChange = { cameraSize = it }, - label = "--camera-size", - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = UiSpacing.CardContent) - .padding(bottom = UiSpacing.CardContent), - ) - } + } + // 只在选择"自定义"时显示输入框 + AnimatedVisibility(videoSource == "camera" && cameraSizeUseCustom) { + SuperTextField( + value = cameraSizeCustomInput, + onValueChange = { cameraSizeCustomInput = it }, + onFocusLost = { + if (cameraSizeCustomInput in scrcpy.cameraSizes) { + cameraSizeDropdownIndex = + scrcpy.cameraSizes.indexOf(cameraSizeCustomInput) + 2 + cameraSizeUseCustom = false + } else { + cameraSizeCustom = cameraSizeCustomInput + } + }, + label = "--camera-size", + useLabelAsPlaceholder = true, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = UiSpacing.CardContent) + .padding(bottom = UiSpacing.CardContent), + ) + } + AnimatedVisibility(videoSource == "camera") { TextField( value = cameraAr, onValueChange = { cameraAr = it }, @@ -343,28 +410,37 @@ internal fun AdvancedConfigPage( .padding(horizontal = UiSpacing.CardContent) .padding(bottom = UiSpacing.CardContent), ) - SuperSlide( + } + AnimatedVisibility(videoSource == "camera") { + SuperSlider( title = "摄像头帧率", summary = "--camera-fps", value = cameraFpsPresetIndex.toFloat(), onValueChange = { value -> - val idx = value.roundToInt().coerceIn(0, CAMERA_FPS_PRESETS.lastIndex) - cameraFps = CAMERA_FPS_PRESETS[idx] + val idx = + value.roundToInt().coerceIn(0, ScrcpyPresets.CameraFps.lastIndex) + cameraFps = ScrcpyPresets.CameraFps[idx] }, - valueRange = 0f..CAMERA_FPS_PRESETS.lastIndex.toFloat(), - steps = (CAMERA_FPS_PRESETS.size - 2).coerceAtLeast(0), + valueRange = 0f..ScrcpyPresets.CameraFps.lastIndex.toFloat(), + steps = (ScrcpyPresets.CameraFps.size - 2).coerceAtLeast(0), unit = "fps", zeroStateText = "默认", showUnitWhenZeroState = false, showKeyPoints = true, - keyPoints = CAMERA_FPS_PRESETS.indices.map { it.toFloat() }, + keyPoints = ScrcpyPresets.CameraFps.indices.map { it.toFloat() }, displayText = cameraFps.toString(), inputHint = "0 或留空表示默认", inputInitialValue = cameraFps.toString(), inputFilter = { it.filter(Char::isDigit) }, - inputValueRange = 0f..Float.MAX_VALUE, - onInputConfirm = { cameraFps = it.toInt() }, + inputValueRange = 0f..UShort.MAX_VALUE.toFloat(), + onInputConfirm = { input -> + input.toIntOrNull() + ?.let { cameraFps = it } + ?: run { cameraFps = 0 } + }, ) + } + AnimatedVisibility(videoSource == "camera") { SuperSwitch( title = "高帧率模式", summary = "--camera-high-speed", @@ -382,20 +458,10 @@ internal fun AdvancedConfigPage( summary = "--audio-source", items = audioSourceItems, selectedIndex = audioSourceIndex, - onSelectedIndexChange = { audioSource = AUDIO_SOURCE_OPTIONS[it].first }, + onSelectedIndexChange = { + audioSource = Shared.AudioSource.entries[it].string + }, ) - if (audioSource == "custom") { - TextField( - value = audioSource, - onValueChange = { audioSource = it }, - label = "--audio-source", - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = UiSpacing.CardContent) - .padding(bottom = UiSpacing.CardContent), - ) - } SuperSwitch( title = "音频双路输出", summary = "--audio-dup", @@ -420,12 +486,13 @@ internal fun AdvancedConfigPage( item { Card { - SuperSlide( + SuperSlider( title = "最大分辨率", summary = "--max-size", value = maxSizePresetIndex.toFloat(), onValueChange = { value -> - val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.MaxSize.lastIndex) + val idx = + value.roundToInt().coerceIn(0, ScrcpyPresets.MaxSize.lastIndex) maxSize = ScrcpyPresets.MaxSize[idx] }, valueRange = 0f..ScrcpyPresets.MaxSize.lastIndex.toFloat(), @@ -439,16 +506,16 @@ internal fun AdvancedConfigPage( inputHint = "0 或留空表示关闭", inputInitialValue = maxSize.toString(), inputFilter = { it.filter(Char::isDigit) }, - inputValueRange = 0f..Float.MAX_VALUE, - onInputConfirm = { maxSize = it.toInt() }, + inputValueRange = 0f..UInt.MAX_VALUE.toFloat(), + onInputConfirm = { input -> input.toIntOrNull()?.let { maxSize = it } }, ) - SuperSlide( + SuperSlider( title = "最大帧率", summary = "--max-fps", value = maxFpsPresetIndex.toFloat(), onValueChange = { value -> val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.MaxFPS.lastIndex) - maxFps = ScrcpyPresets.MaxFPS[idx].toString() + maxFps = if (idx == 0) "" else ScrcpyPresets.MaxFPS[idx].toString() }, valueRange = 0f..ScrcpyPresets.MaxFPS.lastIndex.toFloat(), steps = (ScrcpyPresets.MaxFPS.size - 2).coerceAtLeast(0), @@ -461,7 +528,7 @@ internal fun AdvancedConfigPage( inputHint = "0 或留空表示关闭", inputInitialValue = maxFps, inputFilter = { it.filter(Char::isDigit) }, - inputValueRange = 0f..Float.MAX_VALUE, + inputValueRange = 0f..UShort.MAX_VALUE.toFloat(), onInputConfirm = { maxFps = it }, ) } @@ -472,14 +539,29 @@ internal fun AdvancedConfigPage( SuperArrow( title = "重新获取编码器列表", summary = "--list-encoders", - onClick = onRefreshEncoders, + onClick = { + if (refreshBusy) return@SuperArrow + scope.launch { + refreshBusy = true + try { + scrcpy.refreshEncoders() + snackbarHostState.showSnackbar("编码器列表已刷新") + } catch (e: Exception) { + snackbarHostState.showSnackbar("刷新失败: ${e.message}") + } finally { + refreshBusy = false + } + } + }, ) SuperSpinner( title = "视频编码器", summary = "--video-encoder", items = videoEncoderEntries, selectedIndex = videoEncoderIndex, - onSelectedIndexChange = { videoEncoder = videoEncoderEntries[it].title ?: "" }, + onSelectedIndexChange = { + videoEncoder = videoEncoderEntries[it].title ?: "" + }, ) TextField( value = videoCodecOptions, @@ -496,7 +578,9 @@ internal fun AdvancedConfigPage( summary = "--audio-encoder", items = audioEncoderEntries, selectedIndex = audioEncoderIndex, - onSelectedIndexChange = { audioEncoder = audioEncoderEntries[it].title ?: "" }, + onSelectedIndexChange = { + audioEncoder = audioEncoderEntries[it].title ?: "" + }, ) TextField( value = audioCodecOptions, @@ -531,9 +615,9 @@ internal fun AdvancedConfigPage( horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium), ) { TextField( + label = "width", value = newDisplayWidth, onValueChange = { newDisplayWidth = it; updateNewDisplay() }, - label = "width", singleLine = true, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, @@ -545,9 +629,9 @@ internal fun AdvancedConfigPage( modifier = Modifier.weight(1f), ) TextField( + label = "height", value = newDisplayHeight, onValueChange = { newDisplayHeight = it; updateNewDisplay() }, - label = "height", singleLine = true, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, @@ -559,9 +643,9 @@ internal fun AdvancedConfigPage( modifier = Modifier.weight(1f), ) TextField( + label = "dpi", value = newDisplayDpi, onValueChange = { newDisplayDpi = it; updateNewDisplay() }, - label = "dpi", singleLine = true, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, @@ -600,9 +684,9 @@ internal fun AdvancedConfigPage( horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium), ) { TextField( - value = cropWidth, - onValueChange = { cropWidth = it }, label = "width", + value = cropWidth, + onValueChange = { cropWidth = it; updateCrop() }, singleLine = true, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, @@ -614,9 +698,9 @@ internal fun AdvancedConfigPage( modifier = Modifier.weight(1f), ) TextField( - value = cropHeight, - onValueChange = { cropHeight = it }, label = "height", + value = cropHeight, + onValueChange = { cropHeight = it; updateCrop() }, singleLine = true, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, @@ -633,9 +717,9 @@ internal fun AdvancedConfigPage( horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium), ) { TextField( - value = cropX, - onValueChange = { cropX = it }, label = "x", + value = cropX, + onValueChange = { cropX = it; updateCrop() }, singleLine = true, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, @@ -647,9 +731,9 @@ internal fun AdvancedConfigPage( modifier = Modifier.weight(1f), ) TextField( - value = cropY, - onValueChange = { cropY = it }, label = "y", + value = cropY, + onValueChange = { cropY = it; updateCrop() }, singleLine = true, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, @@ -669,27 +753,3 @@ internal fun AdvancedConfigPage( item { Spacer(Modifier.height(UiSpacing.BottomContent)) } } } - -private fun presetIndexFromInputForAdvancedPage(raw: Int, presets: List): Int { - val exact = presets.indexOf(raw) - if (exact >= 0) return exact - val nearest = presets.withIndex().minByOrNull { (_, preset) -> kotlin.math.abs(preset - raw) } - return nearest?.index ?: 0 -} - -private fun presetIndexFromInputForAdvancedPage(raw: String, presets: List): Int { - if (raw.isBlank()) return 0 - val value = raw.toIntOrNull() ?: return 0 - val exact = presets.indexOf(value) - if (exact >= 0) return exact - val nearest = presets.withIndex().minByOrNull { (_, preset) -> kotlin.math.abs(preset - value) } - return nearest?.index ?: 0 -} - -private fun resolveEncoderTypeLabel(raw: String?): String { - return when (raw?.trim()?.lowercase()) { - "hw" -> "hw" - "sw" -> "sw" - else -> "" - } -} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt index 6502619..c5240e3 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt @@ -14,13 +14,11 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember 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.ui.Modifier import androidx.compose.ui.platform.LocalContext import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade -import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade.ScrcpySessionInfo import io.github.miuzarte.scrcpyforandroid.constants.Defaults import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics @@ -30,7 +28,6 @@ import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy -import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.ListOptions import io.github.miuzarte.scrcpyforandroid.services.EventLogger import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent import io.github.miuzarte.scrcpyforandroid.services.fetchConnectedDeviceInfo @@ -50,7 +47,6 @@ import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonCard import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.TimeoutCancellationException -import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -60,47 +56,14 @@ import top.yukonga.miuix.kmp.basic.SnackbarHostState import top.yukonga.miuix.kmp.extra.SuperBottomSheet import java.net.InetSocketAddress import java.net.Socket -import java.util.concurrent.Executors -private const val ADB_CONNECT_TIMEOUT_MS = 3_000L +private const val ADB_CONNECT_TIMEOUT_MS = 12_000L private const val ADB_KEEPALIVE_INTERVAL_MS = 3_000L private const val ADB_KEEPALIVE_TIMEOUT_MS = 1_500L private const val ADB_AUTO_RECONNECT_DISCOVER_TIMEOUT_MS = 2_000L private const val ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS = 2_000L private const val ADB_TCP_PROBE_TIMEOUT_MS = 500 -private const val DEVICE_SHORTCUT_SEPARATOR = "\u001F" - -private val DeviceShortcutsSaver = - listSaver( - save = { shortcuts -> - // 将 DeviceShortcuts 中的每个设备转换为一行字符串 - shortcuts.devices.map { device -> - listOf( - device.id, - device.name, - device.host, - device.port.toString(), - if (device.online) "1" else "0" - ).joinToString(DEVICE_SHORTCUT_SEPARATOR) - } - }, - restore = { saved -> - // 从保存的字符串列表恢复 DeviceShortcuts - DeviceShortcuts(saved.mapNotNull { line -> - val parts = line.split(DEVICE_SHORTCUT_SEPARATOR) - if (parts.size != 5) return@mapNotNull null - val port = parts[3].toIntOrNull() ?: return@mapNotNull null - DeviceShortcut( - name = parts[1], - host = parts[2], - port = port, - online = parts[4] == "1" - ) - }.toMutableList()) - }, - ) - @Composable fun DeviceTabScreen( contentPadding: PaddingValues, @@ -109,23 +72,12 @@ fun DeviceTabScreen( scrcpy: Scrcpy, snack: SnackbarHostState, scrollBehavior: ScrollBehavior, - - videoEncoderOptions: List, - onVideoEncoderOptionsChange: (List) -> Unit, - onVideoEncoderTypeMapChange: (Map) -> Unit, - audioEncoderOptions: List, - onAudioEncoderOptionsChange: (List) -> Unit, - onAudioEncoderTypeMapChange: (Map) -> Unit, - cameraSizeOptions: List, - onCameraSizeOptionsChange: (List) -> Unit, onSessionStartedChange: (Boolean) -> Unit, - onRefreshEncodersActionChange: ((() -> Unit)?) -> Unit, - onRefreshCameraSizesActionChange: ((() -> Unit)?) -> Unit, - onClearLogsActionChange: ((() -> Unit)?) -> Unit, + onClearLogsActionChange: ((() -> Unit)) -> Unit, onCanClearLogsChange: (Boolean) -> Unit, - onOpenReorderDevicesActionChange: ((() -> Unit)?) -> Unit, + onOpenReorderDevicesActionChange: ((() -> Unit)) -> Unit, onOpenAdvancedPage: () -> Unit, - onOpenFullscreenPage: (ScrcpySessionInfo) -> Unit, + onOpenFullscreenPage: (Scrcpy.Session.SessionInfo) -> Unit, ) { val appSettings = Storage.appSettings val quickDevices = Storage.quickDevices @@ -141,24 +93,14 @@ fun DeviceTabScreen( } // val initialSettings = remember(context) { loadDevicePageSettings(context) } val scope = rememberCoroutineScope() - val adbWorkerDispatcher = remember { - Executors.newSingleThreadExecutor { runnable -> - Thread(runnable, "adb-connect-worker").apply { isDaemon = true } - }.asCoroutineDispatcher() - } // Run adb operations on a dedicated single thread. // Try to avoid blocking UI/recomposition and keeps adb call ordering deterministic. - DisposableEffect(adbWorkerDispatcher) { - onDispose { - adbWorkerDispatcher.close() - } - } - var busy by rememberSaveable { mutableStateOf(false) } var statusLine by rememberSaveable { mutableStateOf("未连接") } var adbConnected by rememberSaveable { mutableStateOf(false) } + var isQuickConnected by rememberSaveable { mutableStateOf(false) } var currentTargetHost by rememberSaveable { mutableStateOf("") } var currentTargetPort by rememberSaveable { mutableIntStateOf(Defaults.ADB_PORT) } var connectedDeviceLabel by rememberSaveable { mutableStateOf("未连接") } @@ -168,7 +110,7 @@ fun DeviceTabScreen( var sessionInfoCodec by rememberSaveable { mutableStateOf("") } var sessionInfoControlEnabled by rememberSaveable { mutableStateOf(false) } var sessionInfo by remember { - mutableStateOf(null) + mutableStateOf(null) } LaunchedEffect( sessionInfoWidth, @@ -178,12 +120,16 @@ fun DeviceTabScreen( sessionInfoControlEnabled ) { sessionInfo = if (sessionInfoDeviceName.isNotBlank()) { - ScrcpySessionInfo( + Scrcpy.Session.SessionInfo( width = sessionInfoWidth, height = sessionInfoHeight, deviceName = sessionInfoDeviceName, - codec = sessionInfoCodec, + codecId = 0, + codecName = sessionInfoCodec, + audioCodecId = 0, controlEnabled = sessionInfoControlEnabled, + host = currentTargetHost, + port = currentTargetPort, ) } else { null @@ -202,7 +148,7 @@ fun DeviceTabScreen( if (currentTargetHost.isNotBlank()) ConnectionTarget( currentTargetHost, - currentTargetPort + currentTargetPort, ) else null val sessionReconnectBlacklistHosts = remember { mutableSetOf() } @@ -212,11 +158,11 @@ fun DeviceTabScreen( } var quickDevicesList by quickDevices.quickDevicesList.asMutableState() - var savedShortcuts = rememberSaveable(quickDevicesList, saver = DeviceShortcutsSaver) { - DeviceShortcuts.unmarshalFrom(quickDevicesList) - } - var quickConnectInput by quickDevices.quickConnectInput.asMutableState() + var savedShortcuts by remember { mutableStateOf(DeviceShortcuts.unmarshalFrom(quickDevicesList)) } + LaunchedEffect(quickDevicesList) { + savedShortcuts = DeviceShortcuts.unmarshalFrom(quickDevicesList) + } // save changes when [savedShortcuts] was modified LaunchedEffect(savedShortcuts) { val serialized = savedShortcuts.marshalToString() @@ -225,15 +171,18 @@ fun DeviceTabScreen( } } + var quickConnectInput by quickDevices.quickConnectInput.asMutableState() + var quickConnectInputTemp by remember(quickConnectInput) { mutableStateOf(quickConnectInput) } + /** * Disconnect the current ADB connection and stop any running scrcpy session. * * Concurrency / thread boundary: - * - Native calls that may block are executed on [adbWorkerDispatcher] using [withContext]. + * - Native calls that may block are executed on ADB dispatcher using adbService.withAdbDispatcher. * - This ensures UI coroutines are never blocked by synchronous native I/O. * * Side effects: - * - Calls `nativeCore.scrcpyStop()` and `nativeCore.adbDisconnect()` (best-effort). + * - Calls `scrcpy.stop()` and `adbService.disconnect()` (best-effort). * - Resets UI-visible connection state: `adbConnected`, `currentTargetHost/Port`, * `sessionInfo`, device capability flags, `statusLine`, and `connectedDeviceLabel`. * - Updates the saved quick-device list via [savedShortcuts.update] when a target is provided. @@ -252,11 +201,9 @@ fun DeviceTabScreen( logMessage: String? = null, showSnackMessage: String? = null, ) { - withContext(adbWorkerDispatcher) { - // Also stops scrcpy. - runCatching { scrcpy.stop() } - runCatching { adbService.disconnect() } - } + // Also stops scrcpy. + runCatching { scrcpy.stop() } + runCatching { adbService.disconnect() } adbConnected = false currentTargetHost = "" currentTargetPort = Defaults.ADB_PORT @@ -326,10 +273,8 @@ fun DeviceTabScreen( * indefinitely. Use a small, caller-chosen timeout to keep UX snappy. */ suspend fun connectWithTimeout(host: String, port: Int) { - return withContext(adbWorkerDispatcher) { - withTimeout(ADB_CONNECT_TIMEOUT_MS) { - adbService.connect(host, port) - } + return withTimeout(ADB_CONNECT_TIMEOUT_MS) { + adbService.connect(host, port) } } @@ -347,17 +292,12 @@ fun DeviceTabScreen( * echo check helps detect that state. */ suspend fun keepAliveCheck(host: String, port: Int): Boolean { - return withContext(adbWorkerDispatcher) { - withTimeout(ADB_KEEPALIVE_TIMEOUT_MS) { - val connected = adbService.isConnected() - if (!connected) { - return@withTimeout false - } - runCatching { - adbService.shell("echo -n 1") - true - }.getOrElse { false } + return withTimeout(ADB_KEEPALIVE_TIMEOUT_MS) { + val connected = adbService.isConnected() + if (!connected) { + return@withTimeout false } + return@withTimeout true } } @@ -391,7 +331,7 @@ fun DeviceTabScreen( fun runBusy(label: String, onFinished: (() -> Unit)? = null, block: suspend () -> Unit) { // For non-adb actions (start/stop/pair/list refresh...). if (busy) return - scope.launch { + scope.launch(kotlinx.coroutines.Dispatchers.IO) { busy = true try { block() @@ -425,10 +365,16 @@ fun DeviceTabScreen( * UI controls remain actionable while background retries occur. * - Errors and timeouts are logged and surfaced similarly to `runBusy`. */ - fun runAdbConnect(label: String, onFinished: (() -> Unit)? = null, block: suspend () -> Unit) { + fun runAdbConnect( + label: String, + onStarted: (() -> Unit)? = null, + onFinished: (() -> Unit)? = null, + block: suspend () -> Unit, + ) { // For manual adb operations from user actions. if (adbConnecting) return scope.launch { + onStarted?.invoke() adbConnecting = true try { block() @@ -465,35 +411,23 @@ fun DeviceTabScreen( suspend fun refreshEncoderLists() { if (!adbConnected) return runCatching { - scrcpy.listOptions(list = ListOptions.ENCODERS) - }.onSuccess { result -> - val lists = result as Scrcpy.ListResult.Encoders - onVideoEncoderOptionsChange(lists.videoEncoders) - onAudioEncoderOptionsChange(lists.audioEncoders) - onVideoEncoderTypeMapChange(lists.videoEncoderTypes) - onAudioEncoderTypeMapChange(lists.audioEncoderTypes) - if (videoEncoder.isNotBlank() && videoEncoder !in videoEncoderOptions) { + scrcpy.refreshEncoders() + }.onSuccess { + // Validate current selections + if (videoEncoder.isNotBlank() && videoEncoder !in scrcpy.videoEncoders) { videoEncoder = "" } - if (audioEncoder.isNotBlank() && audioEncoder !in audioEncoderOptions) { + if (audioEncoder.isNotBlank() && audioEncoder !in scrcpy.audioEncoders) { audioEncoder = "" } - logEvent("编码器列表已刷新: video=${lists.videoEncoders.size} audio=${lists.audioEncoders.size}") - if (lists.videoEncoders.isEmpty() && lists.audioEncoders.isEmpty()) { + logEvent("编码器列表已刷新: video=${scrcpy.videoEncoders.size} audio=${scrcpy.audioEncoders.size}") + if (scrcpy.videoEncoders.isEmpty() && scrcpy.audioEncoders.isEmpty()) { logEvent( "提示: 编码器为空,请检查 server 路径/版本与设备系统日志", Log.WARN ) - val preview = lists.rawOutput.lineSequence().take(20).joinToString(" | ") - if (preview.isNotBlank()) { - logEvent("编码器原始输出: $preview", Log.DEBUG) - } } }.onFailure { e -> - onVideoEncoderOptionsChange(emptyList()) - onAudioEncoderOptionsChange(emptyList()) - onVideoEncoderTypeMapChange(emptyMap()) - onAudioEncoderTypeMapChange(emptyMap()) logEvent( "读取编码器列表失败: ${e.message ?: e.javaClass.simpleName}", Log.ERROR, @@ -507,22 +441,14 @@ fun DeviceTabScreen( suspend fun refreshCameraSizeLists() { if (!adbConnected) return runCatching { - scrcpy.listOptions(ListOptions.CAMERA_SIZES) - }.onSuccess { result -> - val lists = result as Scrcpy.ListResult.CameraSizes - onCameraSizeOptionsChange(lists.sizes) - if (cameraSize.isNotBlank() && cameraSize != "custom" && cameraSize !in lists.sizes) { + scrcpy.refreshCameraSizes() + }.onSuccess { + // Validate current selection + if (cameraSize.isNotBlank() && cameraSize != "custom" && cameraSize !in scrcpy.cameraSizes) { cameraSize = "" } - 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) - } - } + logEvent("camera sizes 已刷新: count=${scrcpy.cameraSizes.size}") }.onFailure { e -> - onCameraSizeOptionsChange(emptyList()) logEvent( "读取 camera sizes 失败: ${e.message ?: e.javaClass.simpleName}", Log.ERROR, @@ -738,7 +664,7 @@ fun DeviceTabScreen( sessionInfoWidth = sessionInfo?.width ?: 0 sessionInfoHeight = sessionInfo?.height ?: 0 sessionInfoDeviceName = sessionInfo?.deviceName.orEmpty() - sessionInfoCodec = sessionInfo?.codec.orEmpty() + sessionInfoCodec = sessionInfo?.codecName.orEmpty() sessionInfoControlEnabled = sessionInfo?.controlEnabled == true } else { sessionInfoWidth = 0 @@ -751,12 +677,6 @@ fun DeviceTabScreen( } DisposableEffect(Unit) { - onRefreshEncodersActionChange { - runBusy("刷新编码器") { refreshEncoderLists() } - } - onRefreshCameraSizesActionChange { - runBusy("刷新 Camera Sizes") { refreshCameraSizeLists() } - } onClearLogsActionChange { EventLogger.clearLogs() } @@ -764,11 +684,8 @@ fun DeviceTabScreen( showReorderSheet = true } onDispose { - onRefreshEncodersActionChange(null) - onRefreshCameraSizesActionChange(null) - onClearLogsActionChange(null) + // canClearLogs 是 DevicePage 特有的状态,需要重置 onCanClearLogsChange(false) - onOpenReorderDevicesActionChange(null) } } @@ -800,8 +717,8 @@ fun DeviceTabScreen( fun sendVirtualButtonAction(action: VirtualButtonAction) { val keycode = action.keycode ?: return runBusy("发送 ${action.title}") { - nativeCore.sessionManager.injectKeycode(0, keycode) - nativeCore.sessionManager.injectKeycode(1, keycode) + nativeCore.session?.injectKeycode(0, keycode) + nativeCore.session?.injectKeycode(1, keycode) } } @@ -870,14 +787,19 @@ fun DeviceTabScreen( onAction = { haptics.contextClick() if (!isConnectedTarget) { - activeDeviceActionId = device.id - runAdbConnect("连接 ADB", onFinished = { activeDeviceActionId = null }) { + runAdbConnect( + "连接 ADB", + onStarted = { activeDeviceActionId = device.id }, + onFinished = { activeDeviceActionId = null }, + ) { disconnectCurrentTargetBeforeConnecting(host, port) try { connectWithTimeout(host, port) adbConnected = true - savedShortcuts = - savedShortcuts.update(host = host, port = port, online = true) + isQuickConnected = false // 标记为快速设备连接 + savedShortcuts = savedShortcuts.update( + host = host, port = port, online = true + ) handleAdbConnected(host, port) } catch (e: Exception) { statusLine = "ADB 连接失败" @@ -889,7 +811,11 @@ fun DeviceTabScreen( } } else { activeDeviceActionId = device.id - runAdbConnect("断开 ADB", onFinished = { activeDeviceActionId = null }) { + runAdbConnect( + "断开 ADB", + onStarted = { activeDeviceActionId = device.id }, + onFinished = { activeDeviceActionId = null }, + ) { sessionReconnectBlacklistHosts += host disconnectAdbConnection( clearQuickOnlineForTarget = ConnectionTarget(host, port), @@ -905,28 +831,37 @@ fun DeviceTabScreen( if (!adbConnected) item { // "快速连接" QuickConnectCard( - input = quickConnectInput, - onValueChange = { quickConnectInput = it }, + input = quickConnectInputTemp, + onValueChange = { + quickConnectInputTemp = it + quickConnectInput = quickConnectInputTemp + }, + // onFocusChange = { quickConnectInput = quickConnectInputTemp }, enabled = !adbConnecting, onAddDevice = { - val target = ConnectionTarget.unmarshalFrom(quickConnectInput) + val target = ConnectionTarget.unmarshalFrom(quickConnectInputTemp) ?: return@QuickConnectCard savedShortcuts = savedShortcuts.upsert( DeviceShortcut(host = target.host, port = target.port) ) - Log.i("SavedShortcuts", "size: ${savedShortcuts.size}, list: $quickDevicesList") + Log.d("SavedShortcuts", "size: ${savedShortcuts.size}, list: $quickDevicesList") scope.launch { snack.showSnackbar("已添加设备: ${target.host}:${target.port}") } }, onConnect = { - val target = ConnectionTarget.unmarshalFrom(quickConnectInput) + val target = ConnectionTarget.unmarshalFrom(quickConnectInputTemp) ?: return@QuickConnectCard - runAdbConnect("连接 ADB", onFinished = { activeDeviceActionId = null }) { + runAdbConnect( + "连接 ADB", + onStarted = { activeDeviceActionId = target.toString() }, + onFinished = { activeDeviceActionId = null }, + ) { disconnectCurrentTargetBeforeConnecting(target.host, target.port) try { connectWithTimeout(target.host, target.port) adbConnected = true + isQuickConnected = true // 标记为快速连接 savedShortcuts = savedShortcuts.update( host = target.host, port = target.port, @@ -956,7 +891,7 @@ fun DeviceTabScreen( onPair = { host, port, code -> runBusy("执行配对") { val resolvedHost = host.trim() - val resolvedPort = port.toIntOrNull() ?: return@runBusy + val resolvedPort = port.trim().toIntOrNull() ?: return@runBusy val resolvedCode = code.trim() val ok = adbService.pair( resolvedHost, @@ -980,26 +915,30 @@ fun DeviceTabScreen( ConfigPanel( busy = busy, audioForwardingSupported = audioForwardingSupported, + cameraMirroringSupported = cameraMirroringSupported, onOpenAdvanced = onOpenAdvancedPage, onStartStopHaptic = { haptics.contextClick() }, onStart = { runBusy("启动 scrcpy") { val options = scrcpyOptions.toClientOptions() val session = scrcpy.start(options) - sessionInfo = session + sessionInfo = session.copy( + host = currentTargetHost, + port = currentTargetPort + ) statusLine = "scrcpy 运行中" @SuppressLint("DefaultLocale") val videoDetail = if (!options.video) { "off" } else { - "${session.codec} ${session.width}x${session.height} " + - "@${String.format("%.1f", videoBitRate / 1_000_000)}Mbps" + "${session.codecName} ${session.width}x${session.height} " + + "@${String.format("%.1f", videoBitRate / 1_000_000f)}Mbps" } val audioDetail = if (!audio) { "off" } else { val playback = if (!options.audioPlayback) "(no-playback)" else "" - "${options.audioCodec} ${videoBitRate / 1_000}Kbps source=${options.audioSource}$playback" + "${options.audioCodec} ${videoBitRate / 1_000f}Kbps source=${options.audioSource}$playback" } logEvent( "scrcpy 已启动: device=${session.deviceName}" + @@ -1010,9 +949,6 @@ fun DeviceTabScreen( scope.launch { snack.showSnackbar("scrcpy 已启动") } - scrcpy.getLastServerCommand()?.let { command -> - logEvent("scrcpy-server args: $command") - } } }, onStop = { @@ -1026,7 +962,23 @@ fun DeviceTabScreen( } } }, - sessionStarted = sessionInfo != null, + sessionInfo = sessionInfo, + onDisconnect = { + runAdbConnect( + "断开 ADB", + onStarted = {}, + onFinished = {}, + ) { + currentTarget?.let { target -> + sessionReconnectBlacklistHosts += target.host + disconnectAdbConnection( + clearQuickOnlineForTarget = target, + logMessage = "ADB 已断开", + showSnackMessage = "ADB 已断开", + ) + } + } + }, ) } @@ -1068,7 +1020,6 @@ fun DeviceTabScreen( LogsPanel(lines = EventLogger.eventLog) } - // TODO: 放进 [AppPageLazyColumn] 里 item { Spacer(Modifier.height(UiSpacing.BottomContent)) } } } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlPage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlPage.kt index 6ac4fc8..4b38956 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlPage.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlPage.kt @@ -20,8 +20,8 @@ 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.NativeCoreFacade.ScrcpySessionInfo import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics +import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy import io.github.miuzarte.scrcpyforandroid.storage.Storage import io.github.miuzarte.scrcpyforandroid.widgets.FullscreenControlScreen import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions @@ -68,11 +68,13 @@ fun FullscreenControlPage( } var session by remember(launch) { mutableStateOf( - ScrcpySessionInfo( + Scrcpy.Session.SessionInfo( width = launch.width, height = launch.height, deviceName = launch.deviceName.ifBlank { "设备" }, - codec = launch.codec.ifBlank { "unknown" }, + codecId = 0, + codecName = launch.codec.ifBlank { "unknown" }, + audioCodecId = 0, controlEnabled = true, ), ) @@ -121,8 +123,12 @@ fun FullscreenControlPage( } suspend fun sendKeycode(keycode: Int) { - nativeCore.sessionManager.injectKeycode(0, keycode) - nativeCore.sessionManager.injectKeycode(1, keycode) + runCatching { + nativeCore.session?.injectKeycode(0, keycode) + nativeCore.session?.injectKeycode(1, keycode) + }.onFailure { e -> + android.util.Log.w("FullscreenControlPage", "sendKeycode failed for keycode=$keycode", e) + } } Scaffold(contentWindowInsets = WindowInsets(0, 0, 0, 0)) { contentPadding -> @@ -139,7 +145,7 @@ fun FullscreenControlPage( currentFps = currentFps, enableBackHandler = false, onInjectTouch = { action, pointerId, x, y, pressure, buttons -> - nativeCore.sessionManager.injectTouch( + nativeCore.session?.injectTouch( action = action, pointerId = pointerId, x = x, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainPage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainPage.kt index eff434a..3f38849 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainPage.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainPage.kt @@ -29,7 +29,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -93,6 +92,7 @@ private sealed interface RootScreen : NavKey { @Composable fun MainPage() { val context = LocalContext.current + val appContext = context.applicationContext val scope = rememberCoroutineScope() val activity = remember(context) { context as? Activity } @@ -105,9 +105,9 @@ fun MainPage() { } } - val nativeCore = remember(context) { NativeCoreFacade.get(context.applicationContext) } - val adbService = remember(context) { NativeAdbService(context) } - val scrcpy = remember(context) { Scrcpy(context) } + val nativeCore = remember(appContext) { NativeCoreFacade.get(appContext) } + val adbService = remember(appContext) { NativeAdbService(appContext) } + val scrcpy = remember(appContext, adbService) { Scrcpy(appContext, adbService) } val snackHostState = remember { SnackbarHostState() } val saveableStateHolder = rememberSaveableStateHolder() @@ -194,16 +194,9 @@ fun MainPage() { val appSettings = Storage.appSettings val scrcpyOptions = Storage.scrcpyOptions - val videoEncoderOptions = remember { mutableStateListOf() } - val audioEncoderOptions = remember { mutableStateListOf() } - val videoEncoderTypeMap = remember { mutableStateMapOf() } - val audioEncoderTypeMap = remember { mutableStateMapOf() } - val cameraSizeOptions = remember { mutableStateListOf() } var sessionStarted by remember { mutableStateOf(false) } - var refreshEncodersAction by remember { mutableStateOf<(() -> Unit)?>(null) } - var refreshCameraSizesAction by remember { mutableStateOf<(() -> Unit)?>(null) } - var clearLogsAction by remember { mutableStateOf<(() -> Unit)?>(null) } - var openReorderDevicesAction by remember { mutableStateOf<(() -> Unit)?>(null) } + var clearLogsAction by remember { mutableStateOf({}) } + var openReorderDevicesAction by remember { mutableStateOf({}) } var canClearLogs by remember { mutableStateOf(false) } var showDeviceMenu by rememberSaveable { mutableStateOf(false) } var fullscreenOrientation by rememberSaveable { @@ -314,7 +307,7 @@ fun MainPage() { canClearLogs = canClearLogs, onDismissRequest = { showDeviceMenu = false }, onReorderDevices = { - openReorderDevicesAction?.invoke() + openReorderDevicesAction() showDeviceMenu = false }, onOpenVirtualButtonOrder = { @@ -322,7 +315,7 @@ fun MainPage() { showDeviceMenu = false }, onClearLogs = { - clearLogsAction?.invoke() + clearLogsAction() showDeviceMenu = false }, ) @@ -338,34 +331,7 @@ fun MainPage() { scrcpy = scrcpy, snack = snackHostState, scrollBehavior = devicesPageScrollBehavior, - videoEncoderOptions = videoEncoderOptions, - onVideoEncoderOptionsChange = { - videoEncoderOptions.clear() - videoEncoderOptions.addAll(it) - }, - onVideoEncoderTypeMapChange = { - videoEncoderTypeMap.clear() - videoEncoderTypeMap.putAll(it) - }, - audioEncoderOptions = audioEncoderOptions, - onAudioEncoderOptionsChange = { - audioEncoderOptions.clear() - audioEncoderOptions.addAll(it) - }, - onAudioEncoderTypeMapChange = { - audioEncoderTypeMap.clear() - audioEncoderTypeMap.putAll(it) - }, - cameraSizeOptions = cameraSizeOptions, - onCameraSizeOptionsChange = { - cameraSizeOptions.clear() - cameraSizeOptions.addAll(it) - }, onSessionStartedChange = { sessionStarted = it }, - onRefreshEncodersActionChange = { refreshEncodersAction = it }, - onRefreshCameraSizesActionChange = { - refreshCameraSizesAction = it - }, onClearLogsActionChange = { clearLogsAction = it }, onCanClearLogsChange = { canClearLogs = it }, onOpenReorderDevicesActionChange = { @@ -385,7 +351,7 @@ fun MainPage() { deviceName = session.deviceName, width = session.width, height = session.height, - codec = session.codec, + codec = session.codecName, ), ), ) @@ -404,7 +370,7 @@ fun MainPage() { SettingsScreen( contentPadding = pagePadding, onOpenReorderDevices = { - openReorderDevicesAction?.invoke() + openReorderDevicesAction() }, onOpenVirtualButtonOrder = { rootBackStack.add(RootScreen.VirtualButtonOrder) @@ -427,14 +393,7 @@ fun MainPage() { } } - val videoEncoder by scrcpyOptions.videoEncoder.asState() - val audioEncoder by scrcpyOptions.audioEncoder.asState() entry(RootScreen.Advanced) { - val videoEncoderDropdownItems = listOf("默认") + videoEncoderOptions - val audioEncoderDropdownItems = listOf("默认") + audioEncoderOptions - val videoEncoderIndex = (videoEncoderOptions.indexOf(videoEncoder) + 1).coerceAtLeast(0) - val audioEncoderIndex = (audioEncoderOptions.indexOf(audioEncoder) + 1).coerceAtLeast(0) - Scaffold( topBar = { TopAppBar( @@ -456,16 +415,7 @@ fun MainPage() { contentPadding = pagePadding, scrollBehavior = advancedPageScrollBehavior, snackbarHostState = snackHostState, - cameraSizeDropdownItems = listOf("默认") + cameraSizeOptions + listOf("自定义"), - cameraSizeOptions = cameraSizeOptions, - videoEncoderDropdownItems = videoEncoderDropdownItems, - videoEncoderTypeMap = videoEncoderTypeMap, - videoEncoderIndex = videoEncoderIndex, - audioEncoderDropdownItems = audioEncoderDropdownItems, - audioEncoderTypeMap = audioEncoderTypeMap, - audioEncoderIndex = audioEncoderIndex, - onRefreshEncoders = { refreshEncodersAction?.invoke() }, - onRefreshCameraSizes = { refreshCameraSizesAction?.invoke() }, + scrcpy = scrcpy, ) } } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsPage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsPage.kt index 3130e87..f500843 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsPage.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsPage.kt @@ -2,6 +2,7 @@ package io.github.miuzarte.scrcpyforandroid.pages import android.content.Intent import android.os.Process +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -12,9 +13,12 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Clear import androidx.compose.material.icons.rounded.FileOpen import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -22,13 +26,15 @@ import androidx.compose.ui.text.font.FontWeight import androidx.core.net.toUri import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn -import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlide +import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlider +import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField import io.github.miuzarte.scrcpyforandroid.storage.AppSettings import io.github.miuzarte.scrcpyforandroid.storage.PreferenceMigration import io.github.miuzarte.scrcpyforandroid.storage.Storage import io.github.miuzarte.scrcpyforandroid.widgets.SectionSmallTitle import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import top.yukonga.miuix.kmp.basic.Card import top.yukonga.miuix.kmp.basic.Icon import top.yukonga.miuix.kmp.basic.IconButton @@ -77,6 +83,10 @@ fun SettingsScreen( ) { val appContext = LocalContext.current.applicationContext val appSettings = Storage.appSettings + var needMigration by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + needMigration = PreferenceMigration(appContext).needsMigration() + } val context = LocalContext.current @@ -98,9 +108,22 @@ fun SettingsScreen( // scrcpy-server var customServerUri by appSettings.customServerUri.asMutableState() var serverRemotePath by appSettings.serverRemotePath.asMutableState() + var serverRemotePathInput by rememberSaveable { + mutableStateOf( + // 默认值留空显示为 hint + if (runBlocking { appSettings.serverRemotePath.isDefaultValue() }) "" + else serverRemotePath + ) + } // ADB var adbKeyName by appSettings.adbKeyName.asMutableState() + var adbKeyNameInput by rememberSaveable { + mutableStateOf( + if (runBlocking { appSettings.adbKeyName.isDefaultValue() }) "" + else adbKeyName + ) + } var adbPairingAutoDiscoverOnDialogOpen by appSettings.adbPairingAutoDiscoverOnDialogOpen.asMutableState() var adbAutoReconnectPairedDevice by appSettings.adbAutoReconnectPairedDevice.asMutableState() @@ -141,7 +164,7 @@ fun SettingsScreen( checked = keepScreenOnWhenStreaming, onCheckedChange = { keepScreenOnWhenStreaming = it }, ) - SuperSlide( + SuperSlider( title = "预览卡高度", summary = "设备页预览卡高度", value = devicePreviewCardHeightDp.toFloat(), @@ -149,12 +172,12 @@ fun SettingsScreen( devicePreviewCardHeightDp = it.roundToInt().coerceAtLeast(120) }, valueRange = 160f..600f, - steps = 439, + steps = 600-160-1, unit = "dp", displayFormatter = { it.roundToInt().toString() }, inputInitialValue = devicePreviewCardHeightDp.toString(), inputFilter = { it.filter(Char::isDigit) }, - inputValueRange = 120f..Float.MAX_VALUE, + inputValueRange = 120f..UShort.MAX_VALUE.toFloat(), onInputConfirm = { input -> input.toIntOrNull()?.let { devicePreviewCardHeightDp = it.coerceAtLeast(120) @@ -218,9 +241,13 @@ fun SettingsScreen( .padding(bottom = UiSpacing.FieldLabelBottom), fontWeight = FontWeight.Medium, ) - TextField( - value = serverRemotePath, - onValueChange = { serverRemotePath = it }, + SuperTextField( + value = serverRemotePathInput, + onValueChange = { serverRemotePathInput = it }, + onFocusLost = { + if (serverRemotePathInput == AppSettings.SERVER_REMOTE_PATH.defaultValue) + serverRemotePathInput = "" + }, label = AppSettings.SERVER_REMOTE_PATH.defaultValue, useLabelAsPlaceholder = true, singleLine = true, @@ -240,9 +267,13 @@ fun SettingsScreen( .padding(top = UiSpacing.CardContent, bottom = UiSpacing.FieldLabelBottom), fontWeight = FontWeight.Medium, ) - TextField( - value = adbKeyName, - onValueChange = { adbKeyName = it }, + SuperTextField( + value = adbKeyNameInput, + onValueChange = { adbKeyNameInput = it }, + onFocusLost = { + if (adbKeyNameInput == AppSettings.ADB_KEY_NAME.defaultValue) + adbKeyNameInput = "" + }, label = AppSettings.ADB_KEY_NAME.defaultValue, useLabelAsPlaceholder = true, singleLine = true, @@ -265,35 +296,41 @@ fun SettingsScreen( ) } - SectionSmallTitle("应用") - Card { - SuperArrow( - title = "恢复旧版本配置", - summary = "从旧版本的 SharedPreferences 恢复至 DataStore", - onClick = { - scope.launch { - val migration = PreferenceMigration(appContext) - migration.migrate(clearSharedPrefs = false) - snackHostState.showSnackbar("迁移完成,应用将重启") + // 这部分应该不会显示出来, + // 应用启动时就会执行迁移与旧数据的删除 + AnimatedVisibility(needMigration) { + SectionSmallTitle("应用") + } + AnimatedVisibility(needMigration) { + Card { + SuperArrow( + title = "恢复旧版本配置", + summary = "从旧版本的 SharedPreferences 恢复至 DataStore", + onClick = { + scope.launch { + val migration = PreferenceMigration(appContext) + migration.migrate(clearSharedPrefs = false) + snackHostState.showSnackbar("迁移完成,应用将重启") - delay(1000) + delay(1000) - val intent = context.packageManager.getLaunchIntentForPackage( - context.packageName - ) - intent?.apply { - addFlags( - Intent.FLAG_ACTIVITY_NEW_TASK - or Intent.FLAG_ACTIVITY_CLEAR_TASK + val intent = context.packageManager.getLaunchIntentForPackage( + context.packageName ) - } - context.startActivity(intent) + intent?.apply { + addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK + or Intent.FLAG_ACTIVITY_CLEAR_TASK + ) + } + context.startActivity(intent) - Process.killProcess(Process.myPid()) - exitProcess(0) - } - }, - ) + Process.killProcess(Process.myPid()) + exitProcess(0) + } + }, + ) + } } SectionSmallTitle("关于") @@ -313,7 +350,6 @@ fun SettingsScreen( } } - // TODO: 放进 [AppPageLazyColumn] 里 item { Spacer(Modifier.height(UiSpacing.BottomContent)) } } } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSlide.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSlider.kt similarity index 64% rename from app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSlide.kt rename to app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSlider.kt index 13240dd..f202b08 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSlide.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSlider.kt @@ -2,16 +2,17 @@ package io.github.miuzarte.scrcpyforandroid.scaffolds import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing import top.yukonga.miuix.kmp.basic.ButtonDefaults import top.yukonga.miuix.kmp.basic.Slider @@ -23,7 +24,7 @@ import top.yukonga.miuix.kmp.extra.SuperDialog import top.yukonga.miuix.kmp.theme.MiuixTheme @Composable -fun SuperSlide( +fun SuperSlider( title: String, summary: String, value: Float, @@ -82,60 +83,71 @@ fun SuperSlide( }, ) - if (showInputDialog) { - var valueText by remember(inputInitialValue) { mutableStateOf(inputInitialValue) } - val activeInputRange = inputValueRange ?: valueRange - SuperDialog( - show = true, - onDismissRequest = { - showInputDialog = false - holdArrow = false - }, - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - ) { - Text(text = inputTitle) - } + SliderInputDialog( + showDialog = showInputDialog, + title = inputTitle, + summary = inputHint, + initialValue = inputInitialValue, + inputFilter = inputFilter, + inputValueRange = inputValueRange ?: valueRange, + onDismissRequest = { showInputDialog = false }, + onDismissFinished = { holdArrow = false }, + onConfirm = { input -> + onInputConfirm(input) + showInputDialog = false + }, + ) +} + +@Composable +private fun SliderInputDialog( + showDialog: Boolean, + title: String, + summary: String, + initialValue: String, + inputFilter: (String) -> String, + inputValueRange: ClosedFloatingPointRange, + onDismissRequest: () -> Unit, + onDismissFinished: () -> Unit, + onConfirm: (String) -> Unit, +) { + SuperDialog( + show = showDialog, + title = title, + summary = summary, + onDismissRequest = onDismissRequest, + onDismissFinished = onDismissFinished, + content = { + var text by remember(initialValue) { mutableStateOf(initialValue) } + TextField( - value = valueText, - onValueChange = { valueText = inputFilter(it) }, - label = inputHint, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier - .fillMaxWidth() - .padding(top = UiSpacing.Large), + modifier = Modifier.padding(bottom = 16.dp), + value = text, + maxLines = 1, + onValueChange = { newValue -> + text = inputFilter(newValue) + }, ) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = UiSpacing.Large), - horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium), - ) { + + Row(horizontalArrangement = Arrangement.SpaceBetween) { TextButton( text = "取消", + onClick = onDismissRequest, modifier = Modifier.weight(1f), - onClick = { - showInputDialog = false - holdArrow = false - }, ) + Spacer(Modifier.width(20.dp)) TextButton( text = "确定", - modifier = Modifier.weight(1f), onClick = { - val inputValue = valueText.trim().toFloatOrNull() - if (inputValue != null && inputValue >= activeInputRange.start && inputValue <= activeInputRange.endInclusive) { - onInputConfirm(valueText.trim()) - showInputDialog = false - holdArrow = false + val inputValue = text.toFloatOrNull() ?: 0f + if (inputValue >= inputValueRange.start && inputValue <= inputValueRange.endInclusive) { + onConfirm(text.trim()) } }, + modifier = Modifier.weight(1f), colors = ButtonDefaults.textButtonColorsPrimary(), ) } - } - } + }, + ) } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperTextField.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperTextField.kt new file mode 100644 index 0000000..c00f273 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperTextField.kt @@ -0,0 +1,121 @@ +package io.github.miuzarte.scrcpyforandroid.scaffolds + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import top.yukonga.miuix.kmp.basic.TextField +import top.yukonga.miuix.kmp.theme.MiuixTheme + +/** + * A wrapped [TextField] component with state change callbacks. + * + * @param value The text to be displayed in the text field. + * @param onValueChange The callback to be called when the value changes. + * @param modifier The modifier to be applied to the [TextField]. + * @param label The label to be displayed when the [TextField] is empty. + * @param enabled Whether the [TextField] is enabled. + * @param readOnly Whether the [TextField] is read-only. + * @param singleLine Whether the text field is single line. + * @param maxLines The maximum number of lines allowed to be displayed. + * @param minLines The minimum number of lines allowed to be displayed. + * @param useLabelAsPlaceholder Whether to use the label as a placeholder. + * @param keyboardOptions The keyboard options to be applied to the [TextField]. + * @param keyboardActions The keyboard actions to be applied to the [TextField]. + * @param visualTransformation The visual transformation to be applied to the [TextField]. + * @param onFocusGained The callback to be called when the text field gains focus. + * @param onFocusLost The callback to be called when the text field loses focus. + * @param insideMargin The margin inside the [TextField]. + * @param backgroundColor The background color of the [TextField]. + * @param cornerRadius The corner radius of the [TextField]. + * @param labelColor The color of the label. + * @param borderColor The color of the border when the [TextField] is focused. + * @param textStyle The text style to be applied to the [TextField]. + * @param cursorBrush The brush to be used for the cursor. + * @param leadingIcon The leading icon to be displayed in the [TextField]. + * @param trailingIcon The trailing icon to be displayed in the [TextField]. + */ +@Composable +fun SuperTextField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + label: String = "", + enabled: Boolean = true, + readOnly: Boolean = false, + singleLine: Boolean = false, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + minLines: Int = 1, + useLabelAsPlaceholder: Boolean = false, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + visualTransformation: VisualTransformation = VisualTransformation.None, + onFocusGained: (() -> Unit)? = null, + onFocusLost: (() -> Unit)? = null, + insideMargin: DpSize = DpSize(16.dp, 16.dp), + backgroundColor: Color = MiuixTheme.colorScheme.secondaryContainer, + cornerRadius: Dp = 16.dp, + labelColor: Color = MiuixTheme.colorScheme.onSecondaryContainer, + borderColor: Color = MiuixTheme.colorScheme.primary, + textStyle: TextStyle = MiuixTheme.textStyles.main, + cursorBrush: Brush = SolidColor(MiuixTheme.colorScheme.primary), + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, +) { + var isFocused by remember { mutableStateOf(false) } + val focusRequester = remember { FocusRequester() } + val interactionSource = remember { MutableInteractionSource() } + + // 监听焦点状态变化并触发回调 + LaunchedEffect(isFocused) { + if (isFocused) onFocusGained?.invoke() + else onFocusLost?.invoke() + } + + TextField( + value = value, + onValueChange = onValueChange, + modifier = modifier + .onFocusChanged { focusState -> + isFocused = focusState.isFocused + } + .focusRequester(focusRequester), + label = label, + enabled = enabled, + readOnly = readOnly, + singleLine = singleLine, + maxLines = maxLines, + minLines = minLines, + useLabelAsPlaceholder = useLabelAsPlaceholder, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + visualTransformation = visualTransformation, + insideMargin = insideMargin, + backgroundColor = backgroundColor, + cornerRadius = cornerRadius, + labelColor = labelColor, + borderColor = borderColor, + textStyle = textStyle, + cursorBrush = cursorBrush, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + interactionSource = interactionSource, + ) +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ClientOptions.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ClientOptions.kt index 9e841ac..a0e56d6 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ClientOptions.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ClientOptions.kt @@ -88,9 +88,9 @@ data class ClientOptions( var maxSize: UShort = 0u, // to server // --video-bit-rate - var videoBitRate: UInt = 0u, // to server + var videoBitRate: Int = 0, // to server // --audio-bit-rate - var audioBitRate: UInt = 0u, // to server + var audioBitRate: Int = 0, // to server // --max-fps var maxFps: String = "", // float to be parsed by the server @@ -115,7 +115,8 @@ data class ClientOptions( // var windowHeight: UShort, // --display-id - var displayId: UInt = 0u, // to server + // -1 for empty text field + var displayId: Int = -1, // to server // var videoBuffer: Tick, // var audioBuffer: Tick, @@ -278,7 +279,7 @@ data class ClientOptions( } if (videoSource == VideoSource.CAMERA) { - if (displayId > 0u) { + if (displayId > 0) { throw IllegalArgumentException( "--display-id is only available with --video-source=display" ) @@ -331,14 +332,14 @@ data class ClientOptions( ) } - if (displayId > 0u && newDisplay.isNotBlank()) { + if (displayId > 0 && newDisplay.isNotBlank()) { throw IllegalArgumentException( "Cannot specify both --display-id and --new-display" ) } if (displayImePolicy != DisplayImePolicy.UNDEFINED - && displayId == 0u && newDisplay.isBlank() + && displayId == 0 && newDisplay.isBlank() ) { throw IllegalArgumentException( "--display-ime-policy is only supported on a secondary display" @@ -466,7 +467,7 @@ data class ClientOptions( } */ - if (control) { + if (!control) { if (turnScreenOff) { throw IllegalArgumentException( "Cannot request to turn screen off if control is disabled" diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt index b12d514..68138c6 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt @@ -3,15 +3,29 @@ package io.github.miuzarte.scrcpyforandroid.scrcpy import android.content.Context import android.net.Uri import android.util.Log +import android.view.KeyEvent import androidx.core.net.toUri import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade -import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade.ScrcpySessionInfo +import io.github.miuzarte.scrcpyforandroid.constants.Defaults +import io.github.miuzarte.scrcpyforandroid.nativecore.AdbSocketStream 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 kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.io.BufferedInputStream +import java.io.BufferedReader +import java.io.DataInputStream +import java.io.DataOutputStream +import java.io.EOFException import java.io.File +import java.io.InputStreamReader +import java.util.ArrayDeque +import kotlin.concurrent.thread +import kotlin.math.roundToInt import kotlin.random.Random import kotlin.random.nextUInt @@ -24,25 +38,25 @@ import kotlin.random.nextUInt * - Audio playback * - Screen control * - * @param context Android context + * @param appContext 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 appContext: Context, + private val adbService: NativeAdbService, 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) + private val session = Session(adbService) + private val nativeCore: NativeCoreFacade = NativeCoreFacade.get(appContext) @Volatile - private var currentSession: ScrcpySessionInfo? = null + private var currentSession: Session.SessionInfo? = null @Volatile private var isRunning: Boolean = false @@ -50,6 +64,19 @@ class Scrcpy( @Volatile private var audioPlayer: ScrcpyAudioPlayer? = null + // Cached encoder and camera size data + private val _videoEncoders = mutableListOf() + private val _audioEncoders = mutableListOf() + private val _videoEncoderTypes = mutableMapOf() + private val _audioEncoderTypes = mutableMapOf() + private val _cameraSizes = mutableListOf() + + val videoEncoders: List get() = _videoEncoders.toList() + val audioEncoders: List get() = _audioEncoders.toList() + val videoEncoderTypes: Map get() = _videoEncoderTypes.toMap() + val audioEncoderTypes: Map get() = _audioEncoderTypes.toMap() + val cameraSizes: List get() = _cameraSizes.toList() + companion object { private const val TAG = "Scrcpy" const val DEFAULT_REMOTE_PATH = "/data/local/tmp/scrcpy-server.jar" @@ -74,9 +101,7 @@ class Scrcpy( } } - suspend fun start( - options: ClientOptions, - ): ScrcpySessionInfo { + suspend fun start(options: ClientOptions): Session.SessionInfo = withContext(Dispatchers.IO) { if (isRunning) { throw IllegalStateException("Scrcpy session is already running") } @@ -109,25 +134,18 @@ class Scrcpy( if (!options.control) { Log.w(TAG, "start(): turnScreenOff ignored because control is disabled") } else { - runCatching { sessionManager.setDisplayPower(on = false) } + runCatching { session.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 + currentSession = info isRunning = true // Setup video consumer (notify NativeCoreFacade to setup decoders) if (options.video) { - nativeCore.onScrcpySessionStarted(session, sessionManager) + nativeCore.onScrcpySessionStarted(info, session) } // Setup audio player @@ -142,7 +160,7 @@ class Scrcpy( ) val player = ScrcpyAudioPlayer(info.audioCodecId) audioPlayer = player - sessionManager.attachAudioConsumer { packet -> + session.attachAudioConsumer { packet -> player.feedPacket(packet.data, packet.ptsUs, packet.isConfig) } } else { @@ -150,13 +168,13 @@ class Scrcpy( } Log.i( - TAG, "start(): Session started successfully - device=${session.deviceName}, " + - "video=${if (options.video) "${session.codec} ${session.width}x${session.height}" else "off"}, " + + TAG, "start(): Session started successfully - device=${info.deviceName}, " + + "video=${if (options.video) "${info.codecName} ${info.width}x${info.height}" else "off"}, " + "audio=${if (options.audio) options.audioCodec.string else "off"}, " + "control=${options.control}" ) - return session + return@withContext info } catch (e: Exception) { Log.e(TAG, "start(): Failed to start scrcpy session", e) @@ -166,19 +184,19 @@ class Scrcpy( } } - suspend fun stop(): Boolean { + suspend fun stop(): Boolean = withContext(Dispatchers.IO) { if (!isRunning) { Log.w(TAG, "stop(): No active session to stop") - return false + return@withContext false } Log.i(TAG, "stop(): Stopping scrcpy session") - return try { + return@withContext try { nativeCore.onScrcpySessionStopped() - sessionManager.clearVideoConsumer() - sessionManager.clearAudioConsumer() - sessionManager.stop() + session.clearVideoConsumer() + session.clearAudioConsumer() + session.stop() audioPlayer?.release() audioPlayer = null isRunning = false @@ -196,11 +214,11 @@ class Scrcpy( adbService.close() } - fun isStarted(): Boolean = isRunning && sessionManager.isStarted() + fun isStarted(): Boolean = isRunning && session.isStarted() - fun getCurrentSession(): ScrcpySessionInfo? = currentSession + fun getCurrentSession(): Session.SessionInfo? = currentSession - fun getLastServerCommand(): String? = sessionManager.getLastServerCommand() + fun getLastServerCommand(): String? = session.getLastServerCommand() sealed class ListResult { data class Encoders( @@ -217,13 +235,49 @@ class Scrcpy( ) : ListResult() } + /** + * Refresh encoder lists from the device. + * Results are cached and can be accessed via videoEncoders, audioEncoders, etc. + * + * @throws Exception if the operation fails + */ + suspend fun refreshEncoders() { + val result = listOptions(ListOptions.ENCODERS) as ListResult.Encoders + _videoEncoders.clear() + _videoEncoders.addAll(result.videoEncoders) + _audioEncoders.clear() + _audioEncoders.addAll(result.audioEncoders) + _videoEncoderTypes.clear() + _videoEncoderTypes.putAll(result.videoEncoderTypes) + _audioEncoderTypes.clear() + _audioEncoderTypes.putAll(result.audioEncoderTypes) + + Log.i(TAG, "refreshEncoders(): video=${_videoEncoders.size}, audio=${_audioEncoders.size}") + } + + /** + * Refresh camera sizes from the device. + * Results are cached and can be accessed via cameraSizes. + * + * @throws Exception if the operation fails + */ + suspend fun refreshCameraSizes() { + val result = listOptions(ListOptions.CAMERA_SIZES) as ListResult.CameraSizes + _cameraSizes.clear() + _cameraSizes.addAll(result.sizes.sortedWith(compareByDescending { size -> + size.substringBefore('x').toIntOrNull() ?: 0 + })) + + Log.i(TAG, "refreshCameraSizes(): sizes=${_cameraSizes.size}") + } + /** * 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 { + suspend fun listOptions(list: ListOptions): ListResult = withContext(Dispatchers.IO) { val serverJar = if (customServerUri.isNullOrBlank()) { extractAssetToCache(serverAsset) } else { @@ -261,7 +315,7 @@ class Scrcpy( val output = adbService.shell("$serverCommand 2>&1") // Parse output based on list option - return when (list) { + return@withContext when (list) { ListOptions.NULL -> { throw IllegalArgumentException("Nothing to do with ListOptions.NULL") } @@ -377,7 +431,7 @@ class Scrcpy( serverJar: File, options: ClientOptions, scid: UInt, - ): ScrcpySessionManager.SessionInfo { + ): Session.SessionInfo { adbService.push(serverJar.toPath(), serverRemotePath) val serverParams = options.toServerParams(scid) @@ -394,18 +448,22 @@ class Scrcpy( // 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(), + val sessionInfo = session.start( serverCommand = serverCommand, scid = scid, options = options, ) + Log.i(TAG, "executeServer(): session.start() returned, checking if session is still active") + if (!session.isStarted()) { + Log.e(TAG, "executeServer(): WARNING - session was cleared immediately after start()!") + } + return sessionInfo } private fun extractAssetToCache(assetPath: String): File { val clean = assetPath.removePrefix("/") - val source = context.assets.open(clean) - val outputFile = File(context.cacheDir, File(clean).name) + val source = appContext.assets.open(clean) + val outputFile = File(appContext.cacheDir, File(clean).name) source.use { input -> outputFile.outputStream().use { output -> input.copyTo(output) } } @@ -414,11 +472,706 @@ class Scrcpy( private fun extractUriToCache(uri: Uri): File { val fileName = "custom-scrcpy-server.jar" - val outputFile = File(context.cacheDir, fileName) - context.contentResolver.openInputStream(uri).use { input -> + 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 } + + /** + * Session manager for scrcpy protocol. + * Handles socket communication, video/audio streaming, and control input. + */ + class Session(private val adbService: NativeAdbService) { + private val mutex = Mutex() + + @Volatile + private var activeSession: ActiveSession? = null + + @Volatile + private var videoConsumer: ((VideoPacket) -> Unit)? = null + + @Volatile + private var videoReaderThread: Thread? = null + + @Volatile + private var audioConsumer: ((AudioPacket) -> Unit)? = null + + @Volatile + private var audioReaderThread: Thread? = null + + @Volatile + private var lastServerCommand: String? = null + private val serverLogBuffer = ArrayDeque() + + suspend fun start( + serverCommand: String, + scid: UInt, + options: ClientOptions, + ): SessionInfo = mutex.withLock { + stopInternal() + serverLogBuffer.clear() + val socketName = socketNameFor(scid.toInt()) + + try { + lastServerCommand = serverCommand + val serverStream = adbService.openShellStream(serverCommand) + val serverLogThread = startServerLogThread(serverStream, socketName) + Thread.sleep(SERVER_BOOT_DELAY_MS) + + val firstStream = openAbstractSocketWithRetry(socketName, expectDummyByte = true) + val firstInput = DataInputStream(BufferedInputStream(firstStream.inputStream)) + + var videoStream: AdbSocketStream? = null + var videoInput: DataInputStream? = null + var audioStream: AdbSocketStream? = null + var audioInput: DataInputStream? = null + var controlStream: AdbSocketStream? = null + + when { + options.video -> { + videoStream = firstStream + videoInput = firstInput + } + + options.audio -> { + audioStream = firstStream + audioInput = firstInput + } + + options.control -> { + controlStream = firstStream + } + + else -> { + throw IllegalArgumentException("At least one of video/audio/control must be enabled") + } + } + + if (options.video && videoStream == null) { + val vStream = openAbstractSocketWithRetry(socketName, expectDummyByte = false) + videoStream = vStream + videoInput = DataInputStream(BufferedInputStream(vStream.inputStream)) + } + + if (options.audio && audioStream == null) { + val aStream = openAbstractSocketWithRetry(socketName, expectDummyByte = false) + audioStream = aStream + audioInput = DataInputStream(BufferedInputStream(aStream.inputStream)) + } + + if (options.control && controlStream == null) { + controlStream = openAbstractSocketWithRetry(socketName, expectDummyByte = false) + } + + val deviceName = readDeviceName(firstInput) + val audioCodecId = + if (options.audio) audioCodecIdFromName(options.audioCodec.string) + else 0 + val codecId: Int + val width: Int + val height: Int + if (options.video) { + val vInput = checkNotNull(videoInput) + codecId = vInput.readInt() + width = vInput.readInt() + height = vInput.readInt() + } else { + codecId = 0 + width = 0 + height = 0 + } + + val sessionInfo = SessionInfo( + deviceName = deviceName, + codecId = codecId, + codecName = codecName(codecId), + width = width, + height = height, + audioCodecId = audioCodecId, + controlEnabled = controlStream != null, + ) + + val controlWriter = controlStream?.let { stream -> + ControlWriter(DataOutputStream(stream.outputStream)) + } + + val newSession = ActiveSession( + info = sessionInfo, + socketName = socketName, + serverStream = serverStream, + serverLogThread = serverLogThread, + videoStream = videoStream, + videoInput = videoInput, + audioStream = audioStream, + audioInput = audioInput, + controlStream = controlStream, + controlWriter = controlWriter, + ) + activeSession = newSession + return sessionInfo + } catch (t: Throwable) { + val tail = snapshotServerLogs() + val detail = if (tail.isBlank()) "" else " | server_log_tail=\n$tail" + throw IllegalStateException("scrcpy start failed: ${t.message}$detail", t) + } + } + + 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 + videoConsumer = consumer + if (videoReaderThread?.isAlive == true) { + return + } + + videoReaderThread = thread(start = true, name = "scrcpy-video-reader") { + try { + while (activeSession === session && !vStream.closed) { + try { + val ptsAndFlags = vInput.readLong() + val packetSize = vInput.readInt() + if (packetSize <= 0) { + continue + } + + val payload = ByteArray(packetSize) + vInput.readFully(payload) + + val config = (ptsAndFlags and PACKET_FLAG_CONFIG) != 0L + val keyFrame = (ptsAndFlags and PACKET_FLAG_KEY_FRAME) != 0L + val ptsUs = ptsAndFlags and PACKET_PTS_MASK + videoConsumer?.invoke( + VideoPacket( + data = payload, + ptsUs = ptsUs, + isConfig = config, + isKeyFrame = keyFrame, + ), + ) + } catch (_: EOFException) { + break + } catch (_: InterruptedException) { + if (activeSession !== session || vStream.closed) { + break + } + Thread.interrupted() + } catch (e: Exception) { + Log.w(TAG, "video reader failed", e) + break + } + } + } finally { + } + } + } + + suspend fun clearVideoConsumer() = mutex.withLock { + videoConsumer = null + } + + suspend fun attachAudioConsumer(consumer: (AudioPacket) -> Unit): Unit = mutex.withLock { + val session = activeSession ?: throw IllegalStateException("scrcpy session not started") + val aInput = session.audioInput ?: return + val aStream = session.audioStream ?: return + audioConsumer = consumer + if (audioReaderThread?.isAlive == true) return + + audioReaderThread = thread(start = true, name = "scrcpy-audio-reader") { + try { + val streamCodecId = try { + aInput.readInt() + } catch (e: Exception) { + Log.w(TAG, "audio codec header read failed", e) + return@thread + } + when (streamCodecId) { + AUDIO_DISABLED -> { + Log.w(TAG, "audio disabled by server") + return@thread + } + + AUDIO_ERROR -> { + Log.e(TAG, "audio stream configuration error from server") + return@thread + } + + else -> { + Log.i( + TAG, + "audio stream codec=0x${streamCodecId.toUInt().toString(16)}" + ) + } + } + + while (activeSession === session && !aStream.closed) { + try { + val ptsAndFlags = aInput.readLong() + val packetSize = aInput.readInt() + if (packetSize <= 0) continue + + val payload = ByteArray(packetSize) + aInput.readFully(payload) + + val isConfig = (ptsAndFlags and PACKET_FLAG_CONFIG) != 0L + val ptsUs = ptsAndFlags and PACKET_PTS_MASK + audioConsumer?.invoke( + AudioPacket( + data = payload, + ptsUs = ptsUs, + isConfig = isConfig + ) + ) + } catch (_: EOFException) { + break + } catch (_: InterruptedException) { + if (activeSession !== session || aStream.closed) { + break + } + Thread.interrupted() + } catch (e: Exception) { + Log.w(TAG, "audio reader failed", e) + break + } + } + } finally { + } + } + } + + suspend fun clearAudioConsumer() = mutex.withLock { + audioConsumer = null + } + + suspend fun injectKeycode(action: Int, keycode: Int, repeat: Int = 0, metaState: Int = 0) = + mutex.withLock { + try { + requireControlWriter().injectKeycode(action, keycode, repeat, metaState) + } catch (e: IllegalStateException) { + Log.w(TAG, "injectKeycode(): control channel not available", e) + } + } + + suspend fun injectText(text: String) = mutex.withLock { + try { + requireControlWriter().injectText(text) + } catch (e: IllegalStateException) { + Log.w(TAG, "injectText(): control channel not available", e) + } + } + + suspend fun injectTouch( + action: Int, + pointerId: Long, + x: Int, + y: Int, + screenWidth: Int, + screenHeight: Int, + pressure: Float, + actionButton: Int, + buttons: Int, + ) = mutex.withLock { + try { + requireControlWriter().injectTouch( + action, + pointerId, + x, + y, + screenWidth, + screenHeight, + pressure, + actionButton, + buttons + ) + } catch (e: IllegalStateException) { + Log.w(TAG, "injectTouch(): control channel not available", e) + } + } + + suspend fun injectScroll( + x: Int, + y: Int, + screenWidth: Int, + screenHeight: Int, + hScroll: Float, + vScroll: Float, + buttons: Int + ) = mutex.withLock { + try { + requireControlWriter().injectScroll( + x, + y, + screenWidth, + screenHeight, + hScroll, + vScroll, + buttons + ) + } catch (e: IllegalStateException) { + Log.w(TAG, "injectScroll(): control channel not available", e) + } + } + + suspend fun pressBackOrScreenOn(action: Int = KeyEvent.ACTION_DOWN) = mutex.withLock { + try { + requireControlWriter().pressBackOrScreenOn(action) + } catch (e: IllegalStateException) { + Log.w(TAG, "pressBackOrScreenOn(): control channel not available", e) + } + } + + suspend fun setDisplayPower(on: Boolean) = mutex.withLock { + try { + requireControlWriter().setDisplayPower(on) + } catch (e: IllegalStateException) { + Log.w(TAG, "setDisplayPower(): control channel not available", e) + } + } + + suspend fun stop() = mutex.withLock { + stopInternal() + } + + private fun stopInternal() { + val session = activeSession ?: return + activeSession = null + videoConsumer = null + audioConsumer = null + + if (Thread.currentThread() !== videoReaderThread) { + runCatching { videoReaderThread?.interrupt() } + runCatching { videoReaderThread?.join(300) } + } + videoReaderThread = null + + if (Thread.currentThread() !== audioReaderThread) { + runCatching { audioReaderThread?.interrupt() } + runCatching { audioReaderThread?.join(300) } + } + audioReaderThread = null + + runCatching { session.controlStream?.close() } + runCatching { session.audioStream?.close() } + runCatching { session.videoStream?.close() } + runCatching { session.serverStream.close() } + if (Thread.currentThread() !== session.serverLogThread) { + runCatching { session.serverLogThread.interrupt() } + runCatching { session.serverLogThread.join(300) } + } + } + + fun isStarted(): Boolean = activeSession != null + + fun getLastServerCommand(): String? = lastServerCommand + + private fun requireControlWriter(): ControlWriter { + val session = activeSession + ?: throw IllegalStateException("scrcpy control channel not available") + return session.controlWriter + ?: throw IllegalStateException("scrcpy control channel not available") + } + + private fun startServerLogThread( + serverStream: AdbSocketStream, + socketName: String + ): Thread { + return thread(start = true, name = "scrcpy-server-log") { + try { + BufferedReader( + InputStreamReader( + serverStream.inputStream, + Charsets.UTF_8 + ) + ).use { reader -> + while (true) { + val line = reader.readLine() ?: break + synchronized(serverLogBuffer) { + if (serverLogBuffer.size >= SERVER_LOG_BUFFER_MAX_LINES) { + serverLogBuffer.removeFirst() + } + serverLogBuffer.addLast(line) + } + Log.i(TAG, "[server:$socketName] $line") + } + } + } catch (e: Exception) { + if (activeSession != null) { + Log.w(TAG, "server log thread failed", e) + } + } + } + } + + private fun snapshotServerLogs(maxLines: Int = 120): String { + val snapshot = synchronized(serverLogBuffer) { + if (serverLogBuffer.isEmpty()) { + return "" + } + val take = maxLines.coerceIn(1, SERVER_LOG_BUFFER_MAX_LINES) + serverLogBuffer.toList().takeLast(take) + } + return snapshot.joinToString("\n") + } + + private suspend fun openAbstractSocketWithRetry( + socketName: String, + expectDummyByte: Boolean + ): AdbSocketStream { + var lastEx: Exception? = null + repeat(CONNECT_RETRY_COUNT) { attempt -> + try { + val stream = adbService.openAbstractSocket(socketName) + if (expectDummyByte) { + val value = stream.inputStream.read() + if (value < 0) { + stream.close() + throw EOFException("scrcpy dummy byte missing") + } + } + return stream + } catch (e: Exception) { + lastEx = e + if (attempt < CONNECT_RETRY_COUNT - 1) Thread.sleep(CONNECT_RETRY_DELAY_MS) + } + } + throw IllegalStateException("Unable to open scrcpy socket '$socketName'", lastEx) + } + + private fun readDeviceName(input: DataInputStream): String { + val buffer = ByteArray(DEVICE_NAME_FIELD_LENGTH) + input.readFully(buffer) + val firstZero = buffer.indexOf(0) + val length = if (firstZero >= 0) firstZero else buffer.size + return buffer.copyOf(length).toString(Charsets.UTF_8) + } + + private fun codecName(codecId: Int) = + when (codecId) { + VIDEO_CODEC_H264 -> "h264" + VIDEO_CODEC_H265 -> "h265" + VIDEO_CODEC_AV1 -> "av1" + else -> "unknown" + } + + private fun audioCodecIdFromName(name: String) = + when (name.lowercase()) { + "opus" -> AUDIO_CODEC_OPUS + "aac" -> AUDIO_CODEC_AAC + "raw" -> AUDIO_CODEC_RAW + "flac" -> AUDIO_CODEC_FLAC + else -> 0 + } + + data class SessionInfo( + val deviceName: String, + val codecId: Int, + val codecName: String, + val width: Int, + val height: Int, + val audioCodecId: Int = 0, + val controlEnabled: Boolean, + val host: String = "", + val port: Int = Defaults.ADB_PORT, + ) + + data class VideoPacket( + val data: ByteArray, + val ptsUs: Long, + val isConfig: Boolean, + val isKeyFrame: Boolean, + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as VideoPacket + if (ptsUs != other.ptsUs) return false + if (isConfig != other.isConfig) return false + if (isKeyFrame != other.isKeyFrame) return false + if (!data.contentEquals(other.data)) return false + return true + } + + override fun hashCode(): Int { + var result = ptsUs.hashCode() + result = 31 * result + isConfig.hashCode() + result = 31 * result + isKeyFrame.hashCode() + result = 31 * result + data.contentHashCode() + return result + } + } + + data class AudioPacket( + val data: ByteArray, + val ptsUs: Long, + val isConfig: Boolean, + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as AudioPacket + if (ptsUs != other.ptsUs) return false + if (isConfig != other.isConfig) return false + if (!data.contentEquals(other.data)) return false + return true + } + + override fun hashCode(): Int { + var result = ptsUs.hashCode() + result = 31 * result + isConfig.hashCode() + result = 31 * result + data.contentHashCode() + return result + } + } + + private data class ActiveSession( + val info: SessionInfo, + val socketName: String, + val serverStream: AdbSocketStream, + val serverLogThread: Thread, + val videoStream: AdbSocketStream?, + val videoInput: DataInputStream?, + val audioStream: AdbSocketStream?, + val audioInput: DataInputStream?, + val controlStream: AdbSocketStream?, + val controlWriter: ControlWriter?, + ) + + private class ControlWriter(private val output: DataOutputStream) { + @Synchronized + fun injectKeycode(action: Int, keycode: Int, repeat: Int, metaState: Int) { + output.writeByte(TYPE_INJECT_KEYCODE) + output.writeByte(action) + output.writeInt(keycode) + output.writeInt(repeat) + output.writeInt(metaState) + output.flush() + } + + @Synchronized + fun injectText(text: String) { + val bytes = text.toByteArray(Charsets.UTF_8) + output.writeByte(TYPE_INJECT_TEXT) + output.writeInt(bytes.size) + output.write(bytes) + output.flush() + } + + @Synchronized + fun injectTouch( + action: Int, + pointerId: Long, + x: Int, + y: Int, + screenWidth: Int, + screenHeight: Int, + pressure: Float, + actionButton: Int, + buttons: Int, + ) { + output.writeByte(TYPE_INJECT_TOUCH_EVENT) + output.writeByte(action) + output.writeLong(pointerId) + writePosition(x, y, screenWidth, screenHeight) + output.writeShort(encodeUnsignedFixedPoint16(pressure)) + output.writeInt(actionButton) + output.writeInt(buttons) + output.flush() + } + + @Synchronized + fun injectScroll( + x: Int, + y: Int, + screenWidth: Int, + screenHeight: Int, + hScroll: Float, + vScroll: Float, + buttons: Int + ) { + output.writeByte(TYPE_INJECT_SCROLL_EVENT) + writePosition(x, y, screenWidth, screenHeight) + output.writeShort(encodeSignedFixedPoint16(hScroll / 16f)) + output.writeShort(encodeSignedFixedPoint16(vScroll / 16f)) + output.writeInt(buttons) + output.flush() + } + + @Synchronized + fun pressBackOrScreenOn(action: Int) { + output.writeByte(TYPE_BACK_OR_SCREEN_ON) + output.writeByte(action) + output.flush() + } + + @Synchronized + fun setDisplayPower(on: Boolean) { + output.writeByte(TYPE_SET_DISPLAY_POWER) + output.writeBoolean(on) + output.flush() + } + + private fun writePosition(x: Int, y: Int, screenWidth: Int, screenHeight: Int) { + output.writeInt(x) + output.writeInt(y) + output.writeShort(screenWidth) + output.writeShort(screenHeight) + } + + private fun encodeUnsignedFixedPoint16(value: Float): Int { + val clamped = value.coerceIn(0f, 1f) + return if (clamped >= 1f) { + 0xffff + } else { + (clamped * 65536f).roundToInt().coerceIn(0, 0xfffe) + } + } + + private fun encodeSignedFixedPoint16(value: Float): Int { + val clamped = value.coerceIn(-1f, 1f) + if (clamped >= 1f) { + return 0x7fff + } + if (clamped <= -1f) { + return -0x8000 + } + return (clamped * 32768f).roundToInt().coerceIn(-0x8000, 0x7ffe) + } + } + + companion object { + private const val SERVER_BOOT_DELAY_MS = 200L + private const val SERVER_LOG_BUFFER_MAX_LINES = 400 + private const val CONNECT_RETRY_COUNT = 100 + private const val CONNECT_RETRY_DELAY_MS = 100L + private const val DEVICE_NAME_FIELD_LENGTH = 64 + private const val PACKET_FLAG_CONFIG = 1L shl 63 + private const val PACKET_FLAG_KEY_FRAME = 1L shl 62 + private const val PACKET_PTS_MASK = (1L shl 62) - 1 + + private const val VIDEO_CODEC_H264 = 0x68323634 + private const val VIDEO_CODEC_H265 = 0x68323635 + private const val VIDEO_CODEC_AV1 = 0x00617631 + private const val AUDIO_CODEC_OPUS = 0x6f707573 + private const val AUDIO_CODEC_AAC = 0x00616163 + private const val AUDIO_CODEC_FLAC = 0x666c6163 + private const val AUDIO_CODEC_RAW = 0x00726177 + + private const val AUDIO_DISABLED = 0 + private const val AUDIO_ERROR = 1 + + private const val TYPE_INJECT_KEYCODE = 0 + private const val TYPE_INJECT_TEXT = 1 + private const val TYPE_INJECT_TOUCH_EVENT = 2 + private const val TYPE_INJECT_SCROLL_EVENT = 3 + private const val TYPE_BACK_OR_SCREEN_ON = 4 + private const val TYPE_SET_DISPLAY_POWER = 10 + + private fun socketNameFor(scid: Int): String { + return "scrcpy_%08x".format(scid) + } + } + } } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ServerParams.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ServerParams.kt index 0402b79..f8b54d6 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ServerParams.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ServerParams.kt @@ -45,8 +45,8 @@ data class ServerParams( var maxSize: UShort, - var videoBitRate: UInt, - var audioBitRate: UInt, + var videoBitRate: Int, + var audioBitRate: Int, var maxFps: String, // float to be parsed by the server var angle: String, // float to be parsed by the server @@ -58,7 +58,7 @@ data class ServerParams( var control: Boolean, - var displayId: UInt, + var displayId: Int, var newDisplay: String, var displayImePolicy: DisplayImePolicy, @@ -96,7 +96,7 @@ data class ServerParams( const val ILLEGAL_CHARACTER_SET: String = " ;'\"*$?&`#\\|<>[]{}()!~\r\n" } - private fun validate(str: String): Unit { + private fun validate(str: String) { // forbid special shell characters if (str.any { it in ILLEGAL_CHARACTER_SET }) { throw IllegalArgumentException("Invalid server param: [$str]") @@ -107,23 +107,28 @@ data class ServerParams( return (extraArgs.toList() + this.toList()).joinToString(SEPARATOR) } - fun toList(): MutableList { + fun toList(simplify: Boolean = false): MutableList { val cmd = mutableListOf() - cmd.add("scid=${scid.toString(16)}") - cmd.add("log_level=${logLevel.string}") + if (!simplify) { + cmd.add("scid=${scid.toString(16)}") + cmd.add("log_level=${logLevel.string}") + } if (!video) { cmd.add("video=false") } - if (videoBitRate > 0u) { + if (videoBitRate > 0) { cmd.add("video_bit_rate=$videoBitRate") } if (!audio) { cmd.add("audio=false") } - if (audioBitRate > 0u) { - cmd.add("audio_bit_rate=$audioBitRate") + if (audioBitRate > 0) { + if (audioCodec.isLossyAudio()!!) { + // 比官方实现多个判断编解码器类型 + cmd.add("audio_bit_rate=$audioBitRate") + } } if (videoCodec != Codec.H264) { cmd.add("video_codec=${videoCodec.string}") @@ -177,7 +182,7 @@ data class ServerParams( // By default, control is true cmd.add("control=false") } - if (displayId > 0u) { + if (displayId >= 0) { cmd.add("display_id=$displayId") } if (cameraId.isNotBlank()) { diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Shared.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Shared.kt index 3a056b7..71cede1 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Shared.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Shared.kt @@ -60,19 +60,33 @@ class Shared { 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 + enum class Codec(val string: String, val displayName: String) { + H264("h264", "H.264"), // default, ignore when passing + H265("h265", "H.265"), + AV1("av1", "AV1"), + OPUS("opus", "Opus"), // default, ignore when passing + AAC("aac", "AAC"), + FLAC("flac", "FLAC"), + RAW("raw", "RAW"); // wav raw + + fun isLossyAudio(): Boolean? = when (this) { + OPUS, AAC -> true + FLAC, RAW -> false + else -> null + } + + enum class Type { VIDEO, AUDIO } companion object { - fun fromString(value: String): Codec { - return entries.find { it.string.equals(value, ignoreCase = true) } ?: H264 - } + val VIDEO = listOf(H264, H265, AV1) + val AUDIO = listOf(OPUS, AAC, FLAC, RAW) + + fun fromString(value: String, type: Type = Type.VIDEO) = + entries.find { it.string.equals(value, ignoreCase = true) } + ?: when (type) { + Type.VIDEO -> H264 + Type.AUDIO -> OPUS + } } } @@ -81,9 +95,9 @@ class Shared { CAMERA("camera"); companion object { - fun fromString(value: String): VideoSource { - return entries.find { it.string.equals(value, ignoreCase = true) } ?: DISPLAY - } + fun fromString(value: String) = + entries.find { it.string.equals(value, ignoreCase = true) } + ?: DISPLAY } } @@ -102,9 +116,9 @@ class Shared { VOICE_PERFORMANCE("voice-performance"); companion object { - fun fromString(value: String): AudioSource { - return entries.find { it.string.equals(value, ignoreCase = true) } ?: AUTO - } + fun fromString(value: String) = + entries.find { it.string.equals(value, ignoreCase = true) } + ?: AUTO } } @@ -115,9 +129,9 @@ class Shared { EXTERNAL("external"); companion object { - fun fromString(value: String): CameraFacing { - return entries.find { it.string.equals(value, ignoreCase = true) } ?: ANY - } + fun fromString(value: String) = + entries.find { it.string.equals(value, ignoreCase = true) } + ?: ANY } } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceConnectionLogic.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceConnectionLogic.kt index 610deff..daebe77 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceConnectionLogic.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceConnectionLogic.kt @@ -1,8 +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 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext internal data class ConnectedDeviceInfo( val model: String, @@ -18,16 +18,15 @@ internal data class ConnectedDeviceInfo( * Fetch basic device properties from an already-connected device. * * Notes: - * - This function issues multiple `adb shell getprop` commands via [nativeCore.adbShell]. - * Each call may block on native I/O, so callers should execute this on the dedicated - * ADB worker dispatcher rather than the UI thread. + * - This function issues multiple `shell getprop` commands via [adbService.shell]. + * Each call may block on native I/O, so it should be called from a coroutine context. * - Returns a lightweight [ConnectedDeviceInfo] structure with commonly-used properties. */ -internal fun fetchConnectedDeviceInfo( +internal suspend fun fetchConnectedDeviceInfo( adbService: NativeAdbService, host: String, port: Int -): ConnectedDeviceInfo = runBlocking { +): ConnectedDeviceInfo = withContext(Dispatchers.IO) { suspend fun prop(name: String): String = runCatching { adbService.shell("getprop $name").trim() }.getOrDefault("") diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/PreferenceMigration.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/PreferenceMigration.kt index 0954abb..266ddc4 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/PreferenceMigration.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/PreferenceMigration.kt @@ -18,7 +18,7 @@ class PreferenceMigration(private val appContext: Context) { private val appSharedPrefs: SharedPreferences by lazy { appContext.getSharedPreferences(AppPreferenceKeys.PREFS_NAME, Context.MODE_PRIVATE) } - + private val sharedPrefs: SharedPreferences by lazy { appContext.getSharedPreferences("nativecore_adb_rsa", Context.MODE_PRIVATE) } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt index d53509d..cdc896a 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt @@ -22,207 +22,215 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") { companion object { val CROP = Pair( stringPreferencesKey("crop"), - "" + "", ) val RECORD_FILENAME = Pair( stringPreferencesKey("record_filename"), - "" + "", ) val VIDEO_CODEC_OPTIONS = Pair( stringPreferencesKey("video_codec_options"), - "" + "", ) val AUDIO_CODEC_OPTIONS = Pair( stringPreferencesKey("audio_codec_options"), - "" + "", ) val VIDEO_ENCODER = Pair( stringPreferencesKey("video_encoder"), - "" + "", ) val AUDIO_ENCODER = Pair( stringPreferencesKey("audio_encoder"), - "" + "", ) val CAMERA_ID = Pair( stringPreferencesKey("camera_id"), - "" + "", ) val CAMERA_SIZE = Pair( stringPreferencesKey("camera_size"), - "" + "", + ) + val CAMERA_SIZE_CUSTOM = Pair( + stringPreferencesKey("camera_size_custom"), + "", + ) + val CAMERA_SIZE_USE_CUSTOM = Pair( + booleanPreferencesKey("camera_size_use_custom"), + false, ) val CAMERA_AR = Pair( stringPreferencesKey("camera_ar"), - "" + "", ) val CAMERA_FPS = Pair( intPreferencesKey("camera_fps"), - 0 + 0, ) val LOG_LEVEL = Pair( stringPreferencesKey("log_level"), - "info" + "info", ) val VIDEO_CODEC = Pair( stringPreferencesKey("video_codec"), - "h264" + "h264", ) val AUDIO_CODEC = Pair( stringPreferencesKey("audio_codec"), - "opus" + "opus", ) val VIDEO_SOURCE = Pair( stringPreferencesKey("video_source"), - "display" + "display", ) val AUDIO_SOURCE = Pair( stringPreferencesKey("audio_source"), - "output" + "output", ) val RECORD_FORMAT = Pair( stringPreferencesKey("record_format"), - "auto" + "auto", ) val CAMERA_FACING = Pair( stringPreferencesKey("camera_facing"), - "any" + "any", ) val MAX_SIZE = Pair( intPreferencesKey("max_size"), - 0 + 0, ) val VIDEO_BIT_RATE = Pair( intPreferencesKey("video_bit_rate"), - 8000000 + 8000000, ) val AUDIO_BIT_RATE = Pair( intPreferencesKey("audio_bit_rate"), - 128000 + 128000, ) val MAX_FPS = Pair( stringPreferencesKey("max_fps"), - "" + "", ) val ANGLE = Pair( stringPreferencesKey("angle"), - "" + "", ) val CAPTURE_ORIENTATION = Pair( intPreferencesKey("capture_orientation"), - 0 + 0, ) val CAPTURE_ORIENTATION_LOCK = Pair( stringPreferencesKey("capture_orientation_lock"), - "unlocked" + "unlocked", ) val DISPLAY_ORIENTATION = Pair( intPreferencesKey("display_orientation"), - 0 + 0, ) val RECORD_ORIENTATION = Pair( intPreferencesKey("record_orientation"), - 0 + 0, ) val DISPLAY_IME_POLICY = Pair( stringPreferencesKey("display_ime_policy"), - "undefined" + "undefined", ) val DISPLAY_ID = Pair( intPreferencesKey("display_id"), - 0 + -1, // undefined ) val SCREEN_OFF_TIMEOUT = Pair( longPreferencesKey("screen_off_timeout"), - -1 + -1, ) val SHOW_TOUCHES = Pair( booleanPreferencesKey("show_touches"), - false + false, ) val FULLSCREEN = Pair( booleanPreferencesKey("fullscreen"), - false + false, ) val CONTROL = Pair( booleanPreferencesKey("control"), - true + true, ) val VIDEO_PLAYBACK = Pair( booleanPreferencesKey("video_playback"), - true + true, ) val AUDIO_PLAYBACK = Pair( booleanPreferencesKey("audio_playback"), - true + true, ) val TURN_SCREEN_OFF = Pair( booleanPreferencesKey("turn_screen_off"), - false + false, ) val STAY_AWAKE = Pair( booleanPreferencesKey("stay_awake"), - false + false, ) val DISABLE_SCREENSAVER = Pair( booleanPreferencesKey("disable_screensaver"), - false + false, ) val POWER_OFF_ON_CLOSE = Pair( booleanPreferencesKey("power_off_on_close"), - false + false, ) val CLEANUP = Pair( booleanPreferencesKey("cleanup"), - true + true, ) val POWER_ON = Pair( booleanPreferencesKey("power_on"), - true + true, ) val VIDEO = Pair( booleanPreferencesKey("video"), - true + true, ) val AUDIO = Pair( booleanPreferencesKey("audio"), - true + true, ) val REQUIRE_AUDIO = Pair( booleanPreferencesKey("require_audio"), - false + false, ) val KILL_ADB_ON_CLOSE = Pair( booleanPreferencesKey("kill_adb_on_close"), - false + false, ) val CAMERA_HIGH_SPEED = Pair( booleanPreferencesKey("camera_high_speed"), - false + false, ) val LIST = Pair( stringPreferencesKey("list"), - "null" + "null", ) val AUDIO_DUP = Pair( booleanPreferencesKey("audio_dup"), - false + false, ) val NEW_DISPLAY = Pair( stringPreferencesKey("new_display"), - "" + "", ) val START_APP = Pair( stringPreferencesKey("start_app"), - "" + "", ) val VD_DESTROY_CONTENT = Pair( booleanPreferencesKey("vd_destroy_content"), - true + true, ) val VD_SYSTEM_DECORATIONS = Pair( booleanPreferencesKey("vd_system_decorations"), - true + true, ) } @@ -234,6 +242,8 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") { val audioEncoder by setting(AUDIO_ENCODER) val cameraId by setting(CAMERA_ID) val cameraSize by setting(CAMERA_SIZE) + val cameraSizeCustom by setting(CAMERA_SIZE_CUSTOM) + val cameraSizeUseCustom by setting(CAMERA_SIZE_USE_CUSTOM) val cameraAr by setting(CAMERA_AR) val cameraFps by setting(CAMERA_FPS) val logLevel by setting(LOG_LEVEL) @@ -294,19 +304,19 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") { videoEncoder = videoEncoder.get(), audioEncoder = audioEncoder.get(), cameraId = cameraId.get(), - cameraSize = cameraSize.get(), + cameraSize = if (!cameraSizeUseCustom.get()) cameraSize.get() else cameraSizeCustom.get(), cameraAr = cameraAr.get(), cameraFps = cameraFps.get().toUShort(), logLevel = LogLevel.valueOf(logLevel.get().uppercase()), - videoCodec = Codec.valueOf(videoCodec.get().uppercase()), - audioCodec = Codec.valueOf(audioCodec.get().uppercase()), - videoSource = VideoSource.valueOf(videoSource.get().uppercase()), - audioSource = AudioSource.valueOf(audioSource.get().uppercase()), + videoCodec = Codec.fromString(videoCodec.get()), + audioCodec = Codec.fromString(audioCodec.get()), + videoSource = VideoSource.fromString(videoSource.get()), + audioSource = AudioSource.fromString(audioSource.get()), recordFormat = ClientOptions.RecordFormat.valueOf(recordFormat.get().uppercase()), - cameraFacing = CameraFacing.valueOf(cameraFacing.get().uppercase()), + cameraFacing = CameraFacing.fromString(cameraFacing.get()), maxSize = maxSize.get().toUShort(), - videoBitRate = videoBitRate.get().toUInt(), - audioBitRate = audioBitRate.get().toUInt(), + videoBitRate = videoBitRate.get(), + audioBitRate = audioBitRate.get(), maxFps = maxFps.get(), angle = angle.get(), captureOrientation = Orientation.fromInt(captureOrientation.get()), @@ -316,7 +326,7 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") { displayOrientation = Orientation.fromInt(displayOrientation.get()), recordOrientation = Orientation.fromInt(recordOrientation.get()), displayImePolicy = DisplayImePolicy.valueOf(displayImePolicy.get().uppercase()), - displayId = displayId.get().toUInt(), + displayId = displayId.get(), screenOffTimeout = Tick(screenOffTimeout.get()), showTouches = showTouches.get(), fullscreen = fullscreen.get(), diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/Settings.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/Settings.kt index 020d165..faf3e5d 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/Settings.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/Settings.kt @@ -54,6 +54,8 @@ abstract class Settings( operator fun getValue(thisRef: Any?, property: KProperty<*>): SettingProperty = this + suspend fun isDefaultValue() = getValue(pair) == pair.defaultValue + suspend fun get(): T = getValue(pair) suspend fun set(value: T) = setValue(pair, value) diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt index d52f8ca..95a9881 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt @@ -2,10 +2,12 @@ package io.github.miuzarte.scrcpyforandroid.widgets import android.annotation.SuppressLint import android.graphics.SurfaceTexture +import android.util.Log import android.view.MotionEvent import android.view.Surface import android.view.TextureView import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable @@ -36,7 +38,6 @@ import androidx.compose.material.icons.rounded.Fullscreen import androidx.compose.material.icons.rounded.LinkOff import androidx.compose.material.icons.rounded.Wifi import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -48,9 +49,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput @@ -67,13 +65,16 @@ 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.NativeCoreFacade.ScrcpySessionInfo import io.github.miuzarte.scrcpyforandroid.constants.Defaults import io.github.miuzarte.scrcpyforandroid.constants.ScrcpyPresets import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut -import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlide +import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts +import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlider +import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField +import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy +import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec import io.github.miuzarte.scrcpyforandroid.storage.Storage import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -97,23 +98,6 @@ import top.yukonga.miuix.kmp.theme.MiuixTheme.isDynamicColor import top.yukonga.miuix.kmp.utils.PressFeedbackType import kotlin.math.roundToInt -// TODO: migrate to scrcpy.Shared - -@Deprecated("scrcpy.Shared.Codec") -private val VIDEO_CODEC_OPTIONS = listOf( - "h264" to "H.264", - "h265" to "H.265", - "av1" to "AV1", -) - -@Deprecated("scrcpy.Shared.Codec") -private val AUDIO_CODEC_OPTIONS = listOf( - "opus" to "Opus", - "aac" to "AAC", - "flac" to "FLAC", - "raw" to "RAW", -) - private object UiMotionActions { const val DOWN = 0 const val UP = 1 @@ -130,7 +114,7 @@ internal fun StatusCard( statusLine: String, adbConnected: Boolean, streaming: Boolean, - sessionInfo: ScrcpySessionInfo?, + sessionInfo: Scrcpy.Session.SessionInfo?, busyLabel: String?, connectedDeviceLabel: String, ) { @@ -183,7 +167,7 @@ internal fun StatusCard( ), secondSmall = StatusSmallCardSpec( "编解码器", - sessionInfo.codec, + sessionInfo.codecName, ), ) } @@ -270,7 +254,7 @@ internal fun PairingCard( @Composable internal fun PreviewCard( - sessionInfo: ScrcpySessionInfo?, + sessionInfo: Scrcpy.Session.SessionInfo?, nativeCore: NativeCoreFacade, previewHeightDp: Int, controlsVisible: Boolean, @@ -376,19 +360,51 @@ internal fun VirtualButtonCard( internal fun ConfigPanel( busy: Boolean, audioForwardingSupported: Boolean, + cameraMirroringSupported: Boolean, onOpenAdvanced: () -> Unit, onStartStopHaptic: (() -> Unit)? = null, onStart: () -> Unit, onStop: () -> Unit, - sessionStarted: Boolean, + sessionInfo: Scrcpy.Session.SessionInfo?, + onDisconnect: () -> Unit = {}, ) { val scrcpyOptions = Storage.scrcpyOptions + val quickDevices = Storage.quickDevices val context = LocalContext.current + val sessionStarted = sessionInfo != null + + // Check if device exists in shortcuts + var quickDevicesList by quickDevices.quickDevicesList.asMutableState() + val savedShortcuts = remember(quickDevicesList) { + DeviceShortcuts.unmarshalFrom(quickDevicesList) + } + val isQuickConnected = remember(sessionInfo, savedShortcuts) { + sessionInfo?.let { info -> + savedShortcuts.get(info.host, info.port) == null + } ?: false + } + + var audio by scrcpyOptions.audio.asMutableState() + + var audioCodec by scrcpyOptions.audioCodec.asMutableState() + val audioCodecItems = remember { Codec.AUDIO.map { it.displayName } } + val audioCodecIndex = Codec.AUDIO.indexOfFirst { + it.string == audioCodec + }.coerceAtLeast(0) + var audioBitRate by scrcpyOptions.audioBitRate.asMutableState() + + var videoCodec by scrcpyOptions.videoCodec.asMutableState() + val videoCodecItems = remember { Codec.VIDEO.map { it.displayName } } + val videoCodecIndex = Codec.VIDEO.indexOfFirst { + it.string == videoCodec + }.coerceAtLeast(0) + var videoBitRate by scrcpyOptions.videoBitRate.asMutableState() + val videoBitRateMbps = videoBitRate / 1_000_000f + SectionSmallTitle("Scrcpy") Card { - var audio by scrcpyOptions.audio.asMutableState() SuperSwitch( title = "音频转发", summary = "转发设备音频到本机 (Android 11+)", @@ -397,64 +413,46 @@ internal fun ConfigPanel( enabled = !sessionStarted && audioForwardingSupported, ) - var audioCodec by scrcpyOptions.audioCodec.asMutableState() - val audioCodecItems = remember { AUDIO_CODEC_OPTIONS.map { it.second } } - val audioCodecIndex = AUDIO_CODEC_OPTIONS.indexOfFirst { - it.first == audioCodec - }.coerceAtLeast(0) - var audioBitRate by scrcpyOptions.audioBitRate.asMutableState() - val audioBitRateKbps = audioBitRate * 1_000f - val audioBitRatePresetIndex = presetIndexFromInput( - audioBitRateKbps.toString(), - ScrcpyPresets.AudioBitRate - ) SuperDropdown( title = "音频编码", summary = "--audio-codec", items = audioCodecItems, selectedIndex = audioCodecIndex, - onSelectedIndexChange = { audioCodec = AUDIO_CODEC_OPTIONS[it].first }, + onSelectedIndexChange = { audioCodec = Codec.AUDIO[it].string }, enabled = !sessionStarted && audio, ) - if (audio && (audioCodec == "opus" || audioCodec == "aac")) { - SuperSlide( + AnimatedVisibility(audio && (audioCodec == "opus" || audioCodec == "aac")) { + SuperSlider( title = "音频码率", summary = "--audio-bit-rate", - value = audioBitRatePresetIndex.toFloat(), + value = ScrcpyPresets.AudioBitRate.indexOfOrNearest(audioBitRate / 1000).toFloat(), onValueChange = { value -> val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.AudioBitRate.lastIndex) - audioBitRate = ScrcpyPresets.AudioBitRate[idx] * 1024 + audioBitRate = ScrcpyPresets.AudioBitRate[idx] * 1000 }, valueRange = 0f..ScrcpyPresets.AudioBitRate.lastIndex.toFloat(), steps = (ScrcpyPresets.AudioBitRate.size - 2).coerceAtLeast(0), enabled = !sessionStarted, unit = "Kbps", - displayText = audioBitRate.toString(), - inputInitialValue = audioBitRate.toString(), + displayText = (audioBitRate / 1000).toString(), + inputInitialValue = (audioBitRate / 1000).toString(), inputFilter = { it.filter(Char::isDigit) }, - inputValueRange = 1f..Float.MAX_VALUE, + inputValueRange = 1f..UShort.MAX_VALUE.toFloat(), onInputConfirm = { raw -> - raw.toIntOrNull()?.takeIf { it > 0 }?.let { audioBitRate = it } + raw.toIntOrNull()?.takeIf { it > 0 }?.let { audioBitRate = it * 1000 } }, ) } - var videoCodec by scrcpyOptions.videoCodec.asMutableState() - val videoCodecItems = remember { VIDEO_CODEC_OPTIONS.map { it.second } } - val videoCodecIndex = VIDEO_CODEC_OPTIONS.indexOfFirst { - it.first == videoCodec - }.coerceAtLeast(0) - var videoBitRate by scrcpyOptions.videoBitRate.asMutableState() - val videoBitRateMbps = videoBitRate / 1_000_000f SuperDropdown( title = "视频编码", summary = "--video-codec", items = videoCodecItems, selectedIndex = videoCodecIndex, - onSelectedIndexChange = { videoCodec = VIDEO_CODEC_OPTIONS[it].first }, + onSelectedIndexChange = { videoCodec = Codec.VIDEO[it].string }, enabled = !sessionStarted, ) - SuperSlide( + SuperSlider( title = "视频码率", summary = "--video-bit-rate", value = videoBitRateMbps, @@ -481,7 +479,7 @@ internal fun ConfigPanel( } } }, - inputValueRange = 0.1f..Float.MAX_VALUE, + inputValueRange = 0.1f..UInt.MAX_VALUE.toFloat(), onInputConfirm = { raw -> raw.toFloatOrNull()?.let { parsed -> if (parsed >= 0.1f) { @@ -498,23 +496,61 @@ internal fun ConfigPanel( enabled = !sessionStarted, ) - TextButton( - text = if (sessionStarted) "停止" else "启动", - onClick = { - onStartStopHaptic?.invoke() - if (sessionStarted) onStop() else onStart() - }, + Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = UiSpacing.CardContent) .padding(bottom = UiSpacing.CardContent), - enabled = !busy, - colors = if (sessionStarted) { - ButtonDefaults.textButtonColors() - } else { - ButtonDefaults.textButtonColorsPrimary() - }, - ) + horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium), + ) { + AnimatedVisibility(!isQuickConnected) { + AnimatedVisibility(!sessionStarted) { + TextButton( + text = "启动", + onClick = { + onStartStopHaptic?.invoke() + onStart() + }, + modifier = Modifier.fillMaxWidth(), + enabled = !busy, + colors = ButtonDefaults.textButtonColorsPrimary(), + ) + } + AnimatedVisibility(sessionStarted) { + TextButton( + text = "停止", + onClick = { + onStartStopHaptic?.invoke() + onStop() + }, + modifier = Modifier.fillMaxWidth(), + enabled = !busy, + ) + } + } + // display them at the same time in quick connection + AnimatedVisibility(isQuickConnected) { + TextButton( + text = "断开", + onClick = { + onStartStopHaptic?.invoke() + onDisconnect() + }, + modifier = Modifier.weight(1f/4f), + enabled = !busy, + ) + TextButton( + text = "启动", + onClick = { + onStartStopHaptic?.invoke() + onStart() + }, + modifier = Modifier.weight(3f/4f), + enabled = !busy, + colors = ButtonDefaults.textButtonColorsPrimary(), + ) + } + } } } @@ -576,10 +612,12 @@ private fun PairingDialog( title = "使用配对码配对设备", summary = "使用六位数的配对码配对新设备", onDismissRequest = { - clearInputs() onDismissRequest() }, - onDismissFinished = onDismissFinished, + onDismissFinished = { + clearInputs() + onDismissFinished() + }, content = { TextField( value = host, @@ -634,7 +672,6 @@ private fun PairingDialog( TextButton( text = "取消", onClick = { - clearInputs() onDismissRequest() }, modifier = Modifier.weight(1f), @@ -643,7 +680,7 @@ private fun PairingDialog( text = "配对", onClick = { onConfirm(host.trim(), port.trim(), code.trim()) - clearInputs() + onDismissRequest() }, enabled = enabled && host.trim().isNotBlank() && @@ -657,15 +694,6 @@ private fun PairingDialog( ) } -private fun presetIndexFromInput(raw: String, presets: List): Int { - if (raw.isBlank()) return 0 - val value = raw.toIntOrNull() ?: return 0 - val exact = presets.indexOf(value) - if (exact >= 0) return exact - val nearest = presets.withIndex().minByOrNull { (_, preset) -> kotlin.math.abs(preset - value) } - return nearest?.index ?: 0 -} - @SuppressLint("DefaultLocale") private fun formatBitRate(value: Float): String = String.format("%.1f", value) @@ -690,7 +718,7 @@ internal fun LogsPanel(lines: List) { */ class TouchEventHandler( private val coroutineScope: CoroutineScope, - private val session: ScrcpySessionInfo, + private val session: Scrcpy.Session.SessionInfo, private val touchAreaSize: IntSize, private val activePointerIds: LinkedHashSet, private val activePointerPositions: LinkedHashMap, @@ -814,7 +842,11 @@ class TouchEventHandler( 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) + runCatching { + onInjectTouch(UiMotionActions.UP, pointerId.toLong(), x, y, 0f, 0) + }.onFailure { e -> + Log.w(FULLSCREEN_TOUCH_LOG_TAG, "releasePointer failed for pointerId=$pointerId", e) + } } activePointerIds -= pointerId activePointerPositions.remove(pointerId) @@ -877,7 +909,15 @@ class TouchEventHandler( activePointerDevicePositions[pointerId] = x to y justPressedPointerIds += pointerId coroutineScope.launch { - onInjectTouch(UiMotionActions.DOWN, pointerId.toLong(), x, y, pressure, 0) + runCatching { + onInjectTouch(UiMotionActions.DOWN, pointerId.toLong(), x, y, pressure, 0) + }.onFailure { e -> + Log.w( + FULLSCREEN_TOUCH_LOG_TAG, + "handlePointerDown failed for pointerId=$pointerId", + e + ) + } } } } @@ -899,7 +939,15 @@ class TouchEventHandler( 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) + runCatching { + onInjectTouch(UiMotionActions.MOVE, pointerId.toLong(), x, y, pressure, 0) + }.onFailure { e -> + Log.w( + FULLSCREEN_TOUCH_LOG_TAG, + "handlePointerMove failed for pointerId=$pointerId", + e + ) + } } } } @@ -937,7 +985,7 @@ class TouchEventHandler( */ @Composable fun FullscreenControlScreen( - session: ScrcpySessionInfo, + session: Scrcpy.Session.SessionInfo, nativeCore: NativeCoreFacade, onDismiss: () -> Unit, showDebugInfo: Boolean, @@ -1071,30 +1119,16 @@ fun FullscreenControlScreen( private fun ScrcpyVideoSurface( modifier: Modifier, nativeCore: NativeCoreFacade, - session: ScrcpySessionInfo?, + session: Scrcpy.Session.SessionInfo?, ) { val surfaceTag = "video-main" var currentSurface by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() LaunchedEffect(session, currentSurface) { - if (session != null && currentSurface != null) { - nativeCore.registerVideoSurface(surfaceTag, currentSurface!!) - } - // Unregistration is handled directly in onSurfaceTextureDestroyed and DisposableEffect - } - - DisposableEffect(Unit) { - onDispose { - val released = currentSurface - if (released != null) { - scope.launch { - nativeCore.unregisterVideoSurface(surfaceTag, released) - } - released.release() - currentSurface = null - } - // If currentSurface is null, onSurfaceTextureDestroyed already handled cleanup + val surface = currentSurface + if (session != null && surface != null && surface.isValid) { + nativeCore.registerVideoSurface(surfaceTag, surface) } } @@ -1108,9 +1142,15 @@ private fun ScrcpyVideoSurface( width: Int, height: Int ) { - currentSurface?.release() // Release stale surface if any @SuppressLint("Recycle") - currentSurface = Surface(surfaceTexture) + val newSurface = Surface(surfaceTexture) + currentSurface = newSurface + // Register immediately when surface becomes available + if (session != null) { + scope.launch { + nativeCore.registerVideoSurface(surfaceTag, newSurface) + } + } } override fun onSurfaceTextureSizeChanged( @@ -1120,15 +1160,9 @@ private fun ScrcpyVideoSurface( ) = Unit override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean { - val released = currentSurface - currentSurface = null - if (released != null) { - scope.launch { - nativeCore.unregisterVideoSurface(surfaceTag, released) - } - released.release() - } - return true + // Return false to keep the SurfaceTexture alive + // This prevents the surface from being destroyed when the view is detached + return false } override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) = Unit @@ -1222,15 +1256,13 @@ internal fun DeviceTile( internal fun QuickConnectCard( input: String, onValueChange: (String) -> Unit, + onFocusChange: (() -> Unit)? = null, onConnect: () -> Unit, onAddDevice: () -> Unit, enabled: Boolean = true, ) { val scope = rememberCoroutineScope() val focusManager = LocalFocusManager.current - val focusRequester = remember { FocusRequester() } - var tempText by remember(input) { mutableStateOf(input) } - var tempFocusState by remember(input) { mutableStateOf(false) } Card( colors = CardDefaults.defaultColors(color = MiuixTheme.colorScheme.primaryContainer), @@ -1259,9 +1291,9 @@ internal fun QuickConnectCard( color = MiuixTheme.colorScheme.onPrimaryContainer, ) } - TextField( - value = tempText, - onValueChange = { tempText = it }, + SuperTextField( + value = input, + onValueChange = onValueChange, label = "IP:PORT", enabled = enabled, useLabelAsPlaceholder = true, @@ -1269,17 +1301,10 @@ internal fun QuickConnectCard( modifier = Modifier .fillMaxWidth() .padding(horizontal = UiSpacing.CardContent) - .padding(bottom = UiSpacing.SectionTitleLeadingGap) - .onFocusChanged { focusState -> - // 失去焦点时回调 - if (!focusState.isFocused && tempFocusState) { - onValueChange(tempText) - } - tempFocusState = focusState.isFocused - } - .focusRequester(focusRequester), + .padding(bottom = UiSpacing.SectionTitleLeadingGap), keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + onFocusLost = onFocusChange, ) Row( modifier = Modifier diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 75221e1..d8b39a6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,6 +11,7 @@ androidxJunit = "1.3.0" espressoCore = "3.7.0" miuix = "0.8.7" material = "1.13.0" +runtime = "1.10.5" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -32,6 +33,7 @@ material = { group = "com.google.android.material", name = "material", version.r miuix = { group = "top.yukonga.miuix.kmp", name = "miuix", version.ref = "miuix" } miuix-icons = { group = "top.yukonga.miuix.kmp", name = "miuix-icons", version.ref = "miuix" } miuix-navigation3-ui = { group = "top.yukonga.miuix.kmp", name = "miuix-navigation3-ui", version.ref = "miuix" } +androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime", version.ref = "runtime" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }