Merge pull request #4 from Miuzarte/dev/refactor

代码库完全重构
This commit is contained in:
謬紗特
2026-04-10 01:25:39 +08:00
committed by GitHub
59 changed files with 9648 additions and 6765 deletions

2
.gitignore vendored
View File

@@ -32,3 +32,5 @@ google-services.json
# Android Profiling
*.hprof
.kiro/

View File

View File

@@ -31,6 +31,7 @@
`keycode 120`,安卓官方([keycodes.h#349](https://android.googlesource.com/platform/frameworks/native/+/master/include/android/keycodes.h#349))的定义为
`System Request / Print Screen key.`,不同的厂商有不同的实现,在某些类原生(`AxionOS`) 上的行为是软重启
- I18N
- More: [TODO.md](TODO.md)
## 建议搭配模块

20
TODO.md Normal file
View File

@@ -0,0 +1,20 @@
# TODO
## PARAMS
- orientation locking
- `-r, --record=file.mp4` 投屏时录制到文件 Record screen to file. The format is determined by the --record-format option if set, or by the file extension.
- `--record-format` 录制格式 Force recording format (mp4, mkv, m4a, mka, opus, aac, flac or wav).
## FEATURES
- 设置项连接设备后马上启用scrcpy会话
### LOWER PRIORITY
顺序无关
- 多配置切换
- [音频延迟](https://developer.android.com/ndk/guides/audio/audio-latency)
- 横屏布局
- 原生悬浮窗 [Petterpx/FloatingX](https://github.com/Petterpx/FloatingX)

View File

@@ -1,6 +1,7 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.compose.compiler)
id("kotlin-parcelize")
}
val defaultAbiList = listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
@@ -23,8 +24,8 @@ android {
applicationId = "io.github.miuzarte.scrcpyforandroid"
minSdk = 26
targetSdk = 36
versionCode = 5
versionName = "0.0.5"
versionCode = 6
versionName = "0.1.0"
externalNativeBuild {
cmake {
@@ -97,6 +98,8 @@ dependencies {
implementation("org.bouncycastle:bcpkix-jdk18on:1.80")
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))

View File

@@ -4,15 +4,28 @@ import android.os.Bundle
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.pages.MainScreen
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?) {
super.onCreate(savedInstanceState)
// initialize settings singleton
Storage.init(applicationContext)
val migration = PreferenceMigration(applicationContext)
runBlocking {
if (migration.needsMigration())
migration.migrate(clearSharedPrefs = true)
}
enableEdgeToEdge()
setContent {
MainPage()
MainScreen()
}
}
}

View File

@@ -1,41 +1,35 @@
package io.github.miuzarte.scrcpyforandroid
import android.content.Context
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.Surface
import androidx.core.net.toUri
import io.github.miuzarte.scrcpyforandroid.nativecore.AnnexBDecoder
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
import io.github.miuzarte.scrcpyforandroid.nativecore.ScrcpyAudioPlayer
import io.github.miuzarte.scrcpyforandroid.nativecore.ScrcpySessionManager
import java.io.File
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import io.github.miuzarte.scrcpyforandroid.nativecore.PersistentVideoRenderer
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.ArrayDeque
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArraySet
import java.util.concurrent.ExecutionException
import java.util.concurrent.Executors
/**
* Facade that centralizes ADB and scrcpy native operations.
* Facade that centralizes video rendering.
*
* Provides synchronous and asynchronous helpers that run on an internal
* single-thread executor to serialize access to the native session manager
* and decoders. Callers should use the provided methods from UI code but
* not perform heavy work on the main thread directly.
* Provides helpers for:
* - Surface/Decoder management for video rendering
* - Video size and FPS monitoring
*/
class NativeCoreFacade(private val appContext: Context) {
class NativeCoreFacade private constructor() {
@Volatile
var session: Scrcpy.Session? = null
private set
private val adbService = NativeAdbService(appContext)
private val sessionManager = ScrcpySessionManager(adbService)
private val executor = Executors.newSingleThreadExecutor()
private val surfaceMap = ConcurrentHashMap<String, Surface>()
private val surfaceIdentityMap = ConcurrentHashMap<String, Int>()
private val decoderMap = ConcurrentHashMap<String, AnnexBDecoder>()
private val sessionLifecycleMutex = Mutex()
private val renderer = PersistentVideoRenderer()
private var activeSurfaceId: Int? = null
private var decoder: AnnexBDecoder? = null
private val videoSizeListeners = CopyOnWriteArraySet<(Int, Int) -> Unit>()
private val videoFpsListeners = CopyOnWriteArraySet<(Float) -> Unit>()
private val mainHandler = Handler(Looper.getMainLooper())
@@ -47,383 +41,79 @@ class NativeCoreFacade(private val appContext: Context) {
private var packetCount: Long = 0
@Volatile
private var audioPlayer: ScrcpyAudioPlayer? = null
private var currentSessionInfo: Scrcpy.Session.SessionInfo? = null
@Volatile
private var currentSessionInfo: ScrcpySessionInfo? = null
fun close() {
suspend fun close() {
sessionLifecycleMutex.withLock {
releaseAllDecoders()
runCatching { sessionManager.stop() }
runCatching { adbService.close() }
executor.shutdown()
renderer.release()
}
}
/**
* Register a rendering Surface for a given `tag`.
* Register the current rendering [surface].
*
* - If the surface is already known and the decoder is active, this is a no-op.
* - If the surface is already active and the decoder exists, this is a no-op.
* - If a decoder exists but cannot switch output surface, a new decoder is created
* and bound to the supplied surface.
* - This method must be called from the UI thread (it is called by the composable
* that owns the TextureView). Native decoder operations are performed synchronously
* on the UI thread only via the decoder API; heavy work happens inside the decoder.
*/
fun registerVideoSurface(tag: String, surface: Surface) {
val newId = System.identityHashCode(surface)
val oldId = surfaceIdentityMap[tag]
if (oldId != null && oldId == newId && decoderMap.containsKey(tag)) {
suspend fun attachVideoSurface(surface: Surface) {
sessionLifecycleMutex.withLock {
if (!surface.isValid) {
Log.w(TAG, "attachVideoSurface(): skip invalid surface")
return
}
Log.i(TAG, "registerVideoSurface(): tag=$tag surfaceId=$newId oldSurfaceId=$oldId")
surfaceMap[tag] = surface
surfaceIdentityMap[tag] = newId
val newId = System.identityHashCode(surface)
if (activeSurfaceId == newId && decoder != null) {
return
}
Log.i(TAG, "attachVideoSurface(): surfaceId=$newId oldSurfaceId=$activeSurfaceId")
activeSurfaceId = newId
renderer.attachDisplaySurface(surface)
val session = currentSessionInfo ?: return
ensureVideoConsumerAttached()
val decoder = decoderMap[tag]
if (decoder != null) {
val switched = decoder.switchOutputSurface(surface)
Log.i(TAG, "registerVideoSurface(): switchOutputSurface tag=$tag success=$switched")
val currentDecoder = decoder
if (currentDecoder != null) {
Log.i(TAG, "attachVideoSurface(): try switch decoder output to persistent surface")
val switched = currentDecoder.switchOutputSurface(renderer.getDecoderSurface())
Log.i(TAG, "attachVideoSurface(): switchOutputSurface success=$switched")
if (switched) {
return
}
}
createOrReplaceDecoder(tag, surface, session)
createOrReplaceDecoder(session)
}
}
/**
* Unregister the rendering Surface previously bound to `tag`.
* Unregister the active rendering [surface].
*
* - If a stale surface reference is supplied (identity mismatch), the request is ignored.
* - When there is no active session, the decoder for the tag is released immediately.
* - This protects the native decoder from feeding into a released Surface.
* - When [releaseDecoder] is false, only the active display target is cleared so a future
* surface can attempt to rebind via `setOutputSurface()`.
* - When [releaseDecoder] is true, the current decoder is also released because the backing
* surface is being destroyed for real.
*/
fun unregisterVideoSurface(tag: String, surface: Surface? = null) {
val currentId = surfaceIdentityMap[tag]
suspend fun detachVideoSurface(surface: Surface? = null, releaseDecoder: Boolean = false) {
sessionLifecycleMutex.withLock {
val currentId = activeSurfaceId
val requestId = surface?.let { System.identityHashCode(it) }
if (requestId != null && currentId != null && requestId != currentId) {
Log.i(
TAG,
"unregisterVideoSurface(): skip stale request tag=$tag requestSurfaceId=$requestId currentSurfaceId=$currentId"
"detachVideoSurface(): skip stale request requestSurfaceId=$requestId currentSurfaceId=$currentId"
)
return
}
Log.i(TAG, "unregisterVideoSurface(): tag=$tag surfaceId=$requestId")
surfaceMap.remove(tag)
surfaceIdentityMap.remove(tag)
if (currentSessionInfo == null) {
decoderMap.remove(tag)?.release()
}
}
/**
* Pair with a device over ADB pairing protocol.
* @return true on successful pairing, false otherwise.
*/
fun adbPair(host: String, port: Int, pairingCode: String): Boolean {
return ioCall { adbService.pair(host, port, pairingCode) }
}
/**
* Discover an ADB pairing service (mDNS) and return its host:port.
* Returns null if no service is found within `timeoutMs`.
*/
fun adbDiscoverPairingService(
timeoutMs: Long = 12_000,
includeLanDevices: Boolean = true,
): Pair<String, Int>? {
return ioCall { adbService.discoverPairingService(timeoutMs, includeLanDevices) }
}
/**
* Discover an ADB connect service for direct connection (mDNS).
*/
fun adbDiscoverConnectService(
timeoutMs: Long = 12_000,
includeLanDevices: Boolean = true,
): Pair<String, Int>? {
return ioCall { adbService.discoverConnectService(timeoutMs, includeLanDevices) }
}
/**
* Connect to an ADB server at the given host and port.
* Returns true on success.
*/
fun adbConnect(host: String, port: Int): Boolean = ioCall { adbService.connect(host, port) }
/**
* Disconnect current ADB connection. Always returns true.
*/
fun adbDisconnect(): Boolean {
ioCall { adbService.disconnect() }
return true
}
/**
* Check whether an ADB connection is currently established.
*/
fun adbIsConnected(): Boolean = ioCall { adbService.isConnected() }
/**
* Execute a shell command over ADB and return its stdout as a string.
*/
fun adbShell(command: String): String = ioCall { adbService.shell(command) }
/**
* Set the local ADB key name used when generating or selecting key files.
*/
fun setAdbKeyName(name: String) {
adbService.keyName = name
}
/**
* Start a scrcpy session synchronously.
*
* - This method runs on the internal single-threaded [executor] via [ioCall], so
* callers block until the start completes. It handles server extraction, starting
* the session manager, creating decoders for any registered surfaces, and setting
* up audio playback when available.
* - After the session is established, `currentSessionInfo` is populated and
* bootstrap packets are reset so newly created decoders can be primed.
*/
fun scrcpyStart(request: ScrcpyStartRequest): ScrcpySessionInfo {
return ioCall {
Log.i(TAG, "scrcpyStart(): request codec=${request.videoCodec} audio=${request.audio}")
val serverJar = if (request.customServerUri.isNullOrBlank()) {
extractAssetToCache(request.serverAsset)
} else {
extractUriToCache(request.customServerUri.toUri())
}
val info = sessionManager.start(
serverJar.toPath(),
ScrcpySessionManager.ScrcpyStartOptions(
serverVersion = request.serverVersion,
serverRemotePath = request.serverRemotePath,
video = !request.noVideo,
audio = request.audio,
control = !request.noControl,
maxSize = request.maxSize,
maxFps = request.maxFps,
videoBitRate = request.videoBitRate,
videoCodec = request.videoCodec,
audioBitRate = request.audioBitRate,
audioCodec = request.audioCodec,
videoEncoder = request.videoEncoder,
videoCodecOptions = request.videoCodecOptions,
audioEncoder = request.audioEncoder,
audioCodecOptions = request.audioCodecOptions,
audioDup = request.audioDup,
audioSource = request.audioSource,
videoSource = request.videoSource,
cameraId = request.cameraId,
cameraFacing = request.cameraFacing,
cameraSize = request.cameraSize,
cameraAr = request.cameraAr,
cameraFps = request.cameraFps,
cameraHighSpeed = request.cameraHighSpeed,
newDisplay = request.newDisplay,
displayId = request.displayId,
crop = request.crop,
),
)
if (request.turnScreenOff) {
if (request.noControl) {
Log.w(TAG, "scrcpyStart(): turnScreenOff ignored because control is disabled")
} else {
runCatching { sessionManager.setDisplayPower(on = false) }
.onFailure { e -> Log.w(TAG, "scrcpyStart(): set display power failed", e) }
}
}
val session = ScrcpySessionInfo(
width = info.width,
height = info.height,
deviceName = info.deviceName,
codec = info.codecName,
controlEnabled = info.controlEnabled,
)
currentSessionInfo = session
releaseAllDecoders()
synchronized(bootstrapLock) {
bootstrapPackets.clear()
latestConfigPacket = null
}
if (!request.noVideo) {
surfaceMap.forEach { (tag, surface) ->
Log.i(TAG, "scrcpyStart(): bind decoder to tag=$tag")
createOrReplaceDecoder(tag, surface, session)
}
}
packetCount = 0
if (!request.noVideo) {
ensureVideoConsumerAttached()
}
// Audio player
audioPlayer?.release()
audioPlayer = null
if (info.audioCodecId != 0 && !request.noAudioPlayback) {
Log.i(
TAG,
"scrcpyStart(): create audio player codecId=0x${
info.audioCodecId.toUInt().toString(16)
}"
)
val player = ScrcpyAudioPlayer(info.audioCodecId)
audioPlayer = player
sessionManager.attachAudioConsumer { packet ->
player.feedPacket(packet.data, packet.ptsUs, packet.isConfig)
}
} else {
Log.i(TAG, "scrcpyStart(): audio playback disabled for this session")
}
session
}
}
/**
* Stop any running scrcpy session and clear all video/audio consumers.
*
* - Executes on the internal executor to keep scrcpy/session-manager operations
* serialized with other IO operations.
* - Releases decoders and audio players and resets session state.
*/
fun scrcpyStop(): Boolean {
ioCall {
releaseAllDecoders()
synchronized(bootstrapLock) {
bootstrapPackets.clear()
latestConfigPacket = null
}
currentSessionInfo = null
sessionManager.clearVideoConsumer()
sessionManager.clearAudioConsumer()
sessionManager.stop()
audioPlayer?.release()
audioPlayer = null
}
return true
}
fun scrcpyListEncoders(
customServerUri: String?,
remotePath: String,
serverVersion: String = "3.3.4"
): ScrcpyEncoderLists {
return ioCall {
val serverJar = if (customServerUri.isNullOrBlank()) {
extractAssetToCache(DEFAULT_SERVER_ASSET)
} else {
extractUriToCache(customServerUri.toUri())
}
val result = sessionManager.listEncoders(
serverJarPath = serverJar.toPath(),
options = ScrcpySessionManager.ScrcpyStartOptions(
serverVersion = serverVersion,
serverRemotePath = remotePath,
),
)
ScrcpyEncoderLists(
videoEncoders = result.videoEncoders,
audioEncoders = result.audioEncoders,
videoEncoderTypes = result.videoEncoderTypes,
audioEncoderTypes = result.audioEncoderTypes,
rawOutput = result.rawOutput,
)
}
}
fun scrcpyListCameraSizes(
customServerUri: String?,
remotePath: String,
serverVersion: String = "3.3.4"
): ScrcpyCameraSizeLists {
return ioCall {
val serverJar = if (customServerUri.isNullOrBlank()) {
extractAssetToCache(DEFAULT_SERVER_ASSET)
} else {
extractUriToCache(customServerUri.toUri())
}
val result = sessionManager.listCameraSizes(
serverJarPath = serverJar.toPath(),
options = ScrcpySessionManager.ScrcpyStartOptions(
serverVersion = serverVersion,
serverRemotePath = remotePath,
),
)
ScrcpyCameraSizeLists(
sizes = result.sizes,
rawOutput = result.rawOutput,
)
}
}
fun scrcpyIsStarted(): Boolean = ioCall { sessionManager.isStarted() }
fun getLastScrcpyServerCommand(): String? = ioCall { sessionManager.getLastServerCommand() }
fun scrcpyInjectKeycode(action: Int, keycode: Int, repeat: Int = 0, metaState: Int = 0) {
ioExecute {
runCatching { sessionManager.injectKeycode(action, keycode, repeat, metaState) }
}
}
fun scrcpyInjectText(text: String) {
ioExecute {
runCatching { sessionManager.injectText(text) }
}
}
fun scrcpyInjectTouch(
action: Int,
x: Int,
y: Int,
screenWidth: Int,
screenHeight: Int,
pressure: Float,
pointerId: Long = 0L,
actionButton: Int = 1,
buttons: Int = 1,
) {
ioExecute {
runCatching {
sessionManager.injectTouch(
action = action,
pointerId = pointerId,
x = x,
y = y,
screenWidth = screenWidth,
screenHeight = screenHeight,
pressure = pressure,
actionButton = actionButton,
buttons = buttons,
)
}
}
}
fun scrcpyInjectScroll(
x: Int,
y: Int,
screenWidth: Int,
screenHeight: Int,
hScroll: Float,
vScroll: Float,
buttons: Int = 0,
) {
ioExecute {
runCatching {
sessionManager.injectScroll(
x,
y,
screenWidth,
screenHeight,
hScroll,
vScroll,
buttons
"detachVideoSurface(): surfaceId=$requestId releaseDecoder=$releaseDecoder"
)
activeSurfaceId = null
renderer.detachDisplaySurface(surface, releaseSurface = false)
if (releaseDecoder) {
Log.i(TAG, "detachVideoSurface(): releasing decoder with destroyed surface")
decoder?.release()
decoder = null
}
}
}
@@ -444,118 +134,80 @@ class NativeCoreFacade(private val appContext: Context) {
videoFpsListeners.remove(listener)
}
fun scrcpyBackOrScreenOn(action: Int = 0) {
ioExecute {
runCatching { sessionManager.pressBackOrScreenOn(action) }
suspend fun scrcpyBackOrTurnScreenOn(action: Int = 0) {
session?.pressBackOrTurnScreenOn(action)
}
/**
* Called by Scrcpy.kt when a session starts.
* Sets up video decoders for registered surfaces.
*/
suspend fun onScrcpySessionStarted(
session: Scrcpy.Session.SessionInfo,
sessionMgr: Scrcpy.Session
) = sessionLifecycleMutex.withLock {
this.session = sessionMgr
currentSessionInfo = session
releaseAllDecoders()
synchronized(bootstrapLock) {
bootstrapPackets.clear()
latestConfigPacket = null
}
if (activeSurfaceId != null) {
Log.i(TAG, "onScrcpySessionStarted(): bind decoder to persistent surface")
createOrReplaceDecoder(session)
}
packetCount = 0
sessionMgr.attachVideoConsumer { packet ->
cacheBootstrapPacket(packet)
packetCount += 1
if (packetCount == 1L || packetCount % 120L == 0L) {
Log.i(
TAG,
"videoFeed(): packets=$packetCount key=${packet.isKeyFrame} cfg=${packet.isConfig} decoder=${decoder != null}"
)
}
val currentDecoder = decoder ?: return@attachVideoConsumer
if (activeSurfaceId == null) return@attachVideoConsumer
runCatching {
currentDecoder.feedAnnexB(
packet.data,
packet.ptsUs,
packet.isKeyFrame,
packet.isConfig
)
}
}
}
private fun extractAssetToCache(assetPath: String): File {
val clean = assetPath.removePrefix("/")
val source = appContext.assets.open(clean)
val outputFile = File(appContext.cacheDir, File(clean).name)
source.use { input ->
outputFile.outputStream().use { output -> input.copyTo(output) }
/**
* Called by Scrcpy.kt when a session stops.
* Cleans up decoders and resets state.
*/
suspend fun onScrcpySessionStopped() = sessionLifecycleMutex.withLock {
session = null
releaseAllDecoders()
synchronized(bootstrapLock) {
bootstrapPackets.clear()
latestConfigPacket = null
}
return outputFile
}
private fun extractUriToCache(uri: Uri): File {
val fileName = "custom-scrcpy-server.jar"
val outputFile = File(appContext.cacheDir, fileName)
appContext.contentResolver.openInputStream(uri).use { input ->
requireNotNull(input) { "Unable to open selected server URI" }
outputFile.outputStream().use { output -> input.copyTo(output) }
}
return outputFile
currentSessionInfo = null
}
companion object {
private const val TAG = "NativeCoreFacade"
private const val DEFAULT_SERVER_ASSET = "bin/scrcpy-server-v3.3.4"
private const val MAX_BOOTSTRAP_PACKETS = 90
@Volatile
private var instance: NativeCoreFacade? = null
// TODO ???
fun get(context: Context): NativeCoreFacade {
return instance ?: synchronized(this) {
instance ?: NativeCoreFacade(context.applicationContext).also { instance = it }
instance ?: NativeCoreFacade().also { instance = it }
}
}
fun defaultStartRequest(
customServerUri: String?,
maxSize: Int,
videoBitRate: Int,
remotePath: String,
videoCodec: String = "h264",
audio: Boolean = true,
audioCodec: String = "opus",
audioBitRate: Int = 128_000,
maxFps: Float = 0f,
noControl: Boolean = false,
videoEncoder: String = "",
videoCodecOptions: String = "",
audioEncoder: String = "",
audioCodecOptions: String = "",
audioDup: Boolean = false,
audioSource: String = "",
videoSource: String = "display",
cameraId: String = "",
cameraFacing: String = "",
cameraSize: String = "",
cameraAr: String = "",
cameraFps: Int = 0,
cameraHighSpeed: Boolean = false,
noAudioPlayback: Boolean = false,
requireAudio: Boolean = false,
turnScreenOff: Boolean = false,
noVideo: Boolean = false,
newDisplay: String = "",
displayId: Int? = null,
crop: String = "",
): ScrcpyStartRequest {
return ScrcpyStartRequest(
serverAsset = DEFAULT_SERVER_ASSET,
customServerUri = customServerUri,
serverVersion = "3.3.4",
serverRemotePath = remotePath,
maxSize = maxSize,
videoBitRate = videoBitRate,
videoCodec = videoCodec,
audio = audio,
audioCodec = audioCodec,
audioBitRate = audioBitRate,
maxFps = maxFps,
noControl = noControl,
videoEncoder = videoEncoder,
videoCodecOptions = videoCodecOptions,
audioEncoder = audioEncoder,
audioCodecOptions = audioCodecOptions,
audioDup = audioDup,
audioSource = audioSource,
videoSource = videoSource,
cameraId = cameraId,
cameraFacing = cameraFacing,
cameraSize = cameraSize,
cameraAr = cameraAr,
cameraFps = cameraFps,
cameraHighSpeed = cameraHighSpeed,
noAudioPlayback = noAudioPlayback,
requireAudio = requireAudio,
turnScreenOff = turnScreenOff,
noVideo = noVideo,
newDisplay = newDisplay,
displayId = displayId,
crop = crop,
)
}
fun nowLogPrefix(): String {
val stamp = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"))
return "[$stamp]"
}
}
private data class CachedPacket(
@@ -588,7 +240,7 @@ class NativeCoreFacade(private val appContext: Context) {
}
/**
* Create or replace a decoder bound to `surface` for `session`.
* Create or replace the active decoder bound to [surface] for [session].
*
* - Chooses MIME type from `session.codec` and constructs an [AnnexBDecoder].
* - The decoder's `onOutputSizeChanged` callback publishes size changes to
@@ -596,23 +248,27 @@ 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(session: Scrcpy.Session.SessionInfo) {
val surface = renderer.getDecoderSurface()
decoder?.release()
decoder = null
Log.i(
TAG,
"createOrReplaceDecoder(): tag=$tag codec=$mime size=${session.width}x${session.height}"
"createOrReplaceDecoder(): " +
"codec=${session.codec?.string ?: "null"}, " +
"size=${session.width}x${session.height}, " +
"persistent=true"
)
val decoder = AnnexBDecoder(
val newDecoder = AnnexBDecoder(
width = session.width,
height = session.height,
outputSurface = surface,
mimeType = mime,
mimeType = when (session.codec) {
Codec.H264 -> "video/avc"
Codec.H265 -> "video/hevc"
Codec.AV1 -> "video/av01"
else -> "video/avc"
},
onOutputSizeChanged = { width, height ->
val current = currentSessionInfo
if (current == null || (current.width == width && current.height == height)) {
@@ -637,8 +293,8 @@ class NativeCoreFacade(private val appContext: Context) {
}
},
)
decoderMap[tag] = decoder
replayBootstrapPackets(decoder)
decoder = newDecoder
replayBootstrapPackets(newDecoder)
}
private fun replayBootstrapPackets(decoder: AnnexBDecoder) {
@@ -654,7 +310,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,
@@ -683,130 +339,8 @@ class NativeCoreFacade(private val appContext: Context) {
}
}
/**
* Attach a single consumer to the session manager to deliver incoming video packets
* to all active decoders.
*
* - Called when a session is active and at least one decoder exists. Packets are
* cached into [bootstrapPackets] to allow late-attaching decoders to catch up.
* - The consumer iterates [decoderMap] and feeds each decoder. Errors are
* isolated with `runCatching` so one codec failure doesn't stop others.
*/
private fun ensureVideoConsumerAttached() {
sessionManager.attachVideoConsumer { packet ->
cacheBootstrapPacket(packet)
packetCount += 1
if (packetCount == 1L || packetCount % 120L == 0L) {
Log.i(
TAG,
"videoFeed(): packets=$packetCount key=${packet.isKeyFrame} cfg=${packet.isConfig} decoders=${decoderMap.size}"
)
}
decoderMap.forEach { (tag, decoder) ->
if (!surfaceIdentityMap.containsKey(tag)) {
return@forEach
}
runCatching {
decoder.feedAnnexB(
packet.data,
packet.ptsUs,
packet.isKeyFrame,
packet.isConfig
)
}
}
}
}
private fun releaseAllDecoders() {
decoderMap.values.forEach { decoder ->
runCatching { decoder.release() }
}
decoderMap.clear()
}
/**
* Execute a blocking IO task on the internal single-thread executor and return its result.
*
* - This serializes access to native adb/session operations and unwraps
* ExecutionException to rethrow the underlying cause.
*/
private fun <T> ioCall(task: () -> T): T {
return try {
executor.submit<T> { task() }.get()
} catch (e: ExecutionException) {
val cause = e.cause
if (cause is Exception) {
throw cause
}
throw RuntimeException(cause ?: e)
runCatching { decoder?.release() }
decoder = null
}
}
/**
* Submit a non-blocking IO task to the internal executor.
*
* - Use this for fire-and-forget native operations where the caller does not need
* a synchronous result (e.g. injecting input, toggling power, etc.).
*/
private fun ioExecute(task: () -> Unit) {
executor.execute(task)
}
}
data class ScrcpyStartRequest(
val serverAsset: String,
val customServerUri: String?,
val serverVersion: String,
val serverRemotePath: String,
val maxSize: Int,
val videoBitRate: Int,
val videoCodec: String = "h264",
val audio: Boolean = true,
val audioCodec: String = "opus",
val audioBitRate: Int = 128_000,
val maxFps: Float = 0f,
val noControl: Boolean = false,
val videoEncoder: String = "",
val videoCodecOptions: String = "",
val audioEncoder: String = "",
val audioCodecOptions: String = "",
val audioDup: Boolean = false,
val audioSource: String = "",
val videoSource: String = "display",
val cameraId: String = "",
val cameraFacing: String = "",
val cameraSize: String = "",
val cameraAr: String = "",
val cameraFps: Int = 0,
val cameraHighSpeed: Boolean = false,
val noAudioPlayback: Boolean = false,
val requireAudio: Boolean = false,
val turnScreenOff: Boolean = false,
val noVideo: Boolean = false,
val newDisplay: String = "",
val displayId: Int? = null,
val crop: String = "",
)
data class ScrcpyEncoderLists(
val videoEncoders: List<String>,
val audioEncoders: List<String>,
val videoEncoderTypes: Map<String, String> = emptyMap(),
val audioEncoderTypes: Map<String, String> = emptyMap(),
val rawOutput: String = "",
)
data class ScrcpyCameraSizeLists(
val sizes: List<String>,
val rawOutput: String = "",
)
data class ScrcpySessionInfo(
val width: Int,
val height: Int,
val deviceName: String,
val codec: String,
val controlEnabled: Boolean,
)

View File

@@ -1,82 +0,0 @@
package io.github.miuzarte.scrcpyforandroid.constants
object AppDefaults {
const val EVENT_LOG_LINES = 512
const val ADB_PORT = 5555
// Devices
const val QUICK_CONNECT_INPUT = ""
const val PAIR_HOST = ""
const val PAIR_PORT = ""
const val PAIR_CODE = ""
const val AUDIO_ENABLED = true
const val AUDIO_CODEC = "opus"
const val AUDIO_BIT_RATE_KBPS = 128
const val AUDIO_BIT_RATE_INPUT = "128"
const val VIDEO_CODEC = "h264"
const val VIDEO_BIT_RATE_MBPS = 8f
const val VIDEO_BIT_RATE_INPUT = "8.0"
const val TURN_SCREEN_OFF = false
const val NO_CONTROL = false
const val NO_VIDEO = false
const val VIDEO_SOURCE_PRESET = "display"
const val DISPLAY_ID = ""
const val CAMERA_ID = ""
const val CAMERA_FACING_PRESET = ""
const val CAMERA_SIZE_PRESET = ""
const val CAMERA_SIZE_CUSTOM = ""
const val CAMERA_AR = ""
const val CAMERA_FPS = ""
const val CAMERA_HIGH_SPEED = false
const val AUDIO_SOURCE_PRESET = "output"
const val AUDIO_SOURCE_CUSTOM = ""
const val AUDIO_DUP = false
const val NO_AUDIO_PLAYBACK = false
const val REQUIRE_AUDIO = false
const val MAX_SIZE_INPUT = ""
const val MAX_FPS_INPUT = ""
const val VIDEO_ENCODER = ""
const val VIDEO_CODEC_OPTION = ""
const val AUDIO_ENCODER = ""
const val AUDIO_CODEC_OPTION = ""
const val NEW_DISPLAY_WIDTH = ""
const val NEW_DISPLAY_HEIGHT = ""
const val NEW_DISPLAY_DPI = ""
const val CROP_WIDTH = ""
const val CROP_HEIGHT = ""
const val CROP_X = ""
const val CROP_Y = ""
// Settings
const val THEME_BASE_INDEX = 0
const val MONET = false
const val FULLSCREEN_DEBUG_INFO = false
const val SHOW_FULLSCREEN_VIRTUAL_BUTTONS = true
const val KEEP_SCREEN_ON_WHEN_STREAMING = false
const val DEVICE_PREVIEW_CARD_HEIGHT_DP = 320
const val PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT = true
const val VIRTUAL_BUTTONS_LAYOUT =
"more:1,app_switch:1,home:0,back:1,menu:0,notification:0,volume_up:0,volume_down:0,volume_mute:0,power:0,screenshot:0"
const val CUSTOM_SERVER_URI = ""
const val SERVER_REMOTE_PATH = "/data/local/tmp/scrcpy-server.jar"
const val SERVER_REMOTE_PATH_INPUT = ""
const val ADB_KEY_NAME = "scrcpy"
const val ADB_KEY_NAME_INPUT = ""
const val ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN = true
const val ADB_AUTO_RECONNECT_PAIRED_DEVICE = true
const val ADB_MDNS_LAN_DISCOVERY = true
}

View File

@@ -1,81 +0,0 @@
package io.github.miuzarte.scrcpyforandroid.constants
object AppPreferenceKeys {
const val PREFS_NAME = "scrcpy_app_prefs"
const val NATIVE_ADB_KEY_PREFS_NAME = "nativecore_adb_rsa"
const val NATIVE_ADB_PRIVATE_KEY = "priv"
// Devices
const val QUICK_DEVICES = "quick_devices"
const val QUICK_CONNECT_INPUT = "quick_connect_input"
const val PAIR_HOST = "pair_host"
const val PAIR_PORT = "pair_port"
const val PAIR_CODE = "pair_code"
const val AUDIO_ENABLED = "audio_enabled"
const val AUDIO_CODEC = "audio_codec"
const val AUDIO_BIT_RATE_INPUT = "audio_bit_rate_input"
const val AUDIO_BIT_RATE_KBPS = "audio_bit_rate_kbps"
const val VIDEO_CODEC = "video_codec"
const val VIDEO_BIT_RATE_MBPS = "video_bit_rate_mbps"
const val VIDEO_BIT_RATE_INPUT = "video_bit_rate_input"
const val TURN_SCREEN_OFF = "turn_screen_off"
const val NO_CONTROL = "no_control"
const val NO_VIDEO = "no_video"
const val VIDEO_SOURCE_PRESET = "video_source_preset"
const val DISPLAY_ID = "display_id"
const val CAMERA_ID = "camera_id"
const val CAMERA_FACING_PRESET = "camera_facing_preset"
const val CAMERA_SIZE_PRESET = "camera_size_preset"
const val CAMERA_SIZE_CUSTOM = "camera_size_custom"
const val CAMERA_AR = "camera_ar"
const val CAMERA_FPS = "camera_fps"
const val CAMERA_HIGH_SPEED = "camera_high_speed"
const val AUDIO_SOURCE_PRESET = "audio_source_preset"
const val AUDIO_SOURCE_CUSTOM = "audio_source_custom"
const val AUDIO_DUP = "audio_dup"
const val NO_AUDIO_PLAYBACK = "no_audio_playback"
const val REQUIRE_AUDIO = "require_audio"
const val MAX_SIZE_INPUT = "max_size_input"
const val MAX_FPS_INPUT = "max_fps_input"
const val VIDEO_ENCODER = "video_encoder"
const val VIDEO_CODEC_OPTION = "video_codec_options"
const val AUDIO_ENCODER = "audio_encoder"
const val AUDIO_CODEC_OPTION = "audio_codec_options"
const val NEW_DISPLAY_WIDTH = "new_display_width"
const val NEW_DISPLAY_HEIGHT = "new_display_height"
const val NEW_DISPLAY_DPI = "new_display_dpi"
const val CROP_WIDTH = "crop_width"
const val CROP_HEIGHT = "crop_height"
const val CROP_X = "crop_x"
const val CROP_Y = "crop_y"
// Settings
const val THEME_BASE_INDEX = "theme_base_index"
const val MONET = "monet"
const val FULLSCREEN_DEBUG_INFO = "fullscreen_debug_info"
const val SHOW_FULLSCREEN_VIRTUAL_BUTTONS = "show_fullscreen_virtual_buttons"
const val KEEP_SCREEN_ON_WHEN_STREAMING = "keep_screen_on_when_streaming"
const val DEVICE_PREVIEW_CARD_HEIGHT_DP = "device_preview_card_height_dp"
const val PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT = "preview_virtual_button_show_text"
const val VIRTUAL_BUTTONS_LAYOUT = "virtual_buttons_layout"
const val CUSTOM_SERVER_URI = "custom_server_uri"
const val SERVER_REMOTE_PATH = "server_remote_path"
const val ADB_KEY_NAME = "adb_key_name"
const val ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN = "adb_pairing_auto_discover_on_dialog_open"
const val ADB_AUTO_RECONNECT_PAIRED_DEVICE = "adb_auto_reconnect_paired_device"
const val ADB_MDNS_LAN_DISCOVERY = "adb_mdns_lan_discovery"
}

View File

@@ -0,0 +1,5 @@
package io.github.miuzarte.scrcpyforandroid.constants
object Defaults {
const val ADB_PORT = 5555;
}

View File

@@ -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)
val MaxFPS = listOf(0, 24, 30, 45, 60, 90, 120)
val AudioBitRate = listOf(32, 64, 96, 128, 160, 192, 256, 320, 384, 512)
/**
* 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<T : Comparable<T>>(val values: List<T>) {
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<Int>.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<Int>.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(0, 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
}

View File

@@ -0,0 +1,16 @@
package io.github.miuzarte.scrcpyforandroid.constants
import top.yukonga.miuix.kmp.theme.ColorSchemeMode
object ThemeModes {
data class Option(
val label: String,
val mode: ColorSchemeMode,
)
val baseOptions = listOf(
Option("跟随系统", ColorSchemeMode.System),
Option("浅色", ColorSchemeMode.Light),
Option("深色", ColorSchemeMode.Dark),
)
}

View File

@@ -16,8 +16,10 @@ object UiSpacing {
val SectionTitleTop = 12.dp
val SectionTitleBottom = 6.dp
val FieldLabelBottom = 4.dp
val CardContent = 12.dp
val Content = 12.dp
val ContentVertical = 12.dp
val ContentHorizontal = 12.dp
val CardTitle = 16.dp
val BottomContent = 64.dp
val BottomSheetBottom = 16.dp
val PageBottom = 64.dp
val SheetBottom = 16.dp
}

View File

@@ -1,18 +1,188 @@
package io.github.miuzarte.scrcpyforandroid.models
internal data class ConnectionTarget(
import io.github.miuzarte.scrcpyforandroid.constants.Defaults
// Composable 用, 不可变 List
class DeviceShortcuts(val devices: List<DeviceShortcut>) : List<DeviceShortcut> by devices {
fun marshalToString(
separator: String = DEFAULT_SEPARATOR,
): String = joinToString(separator) { it.marshalToString() }
companion object {
const val DEFAULT_SEPARATOR = "\n"
fun unmarshalFrom(
s: String,
separator: String = DEFAULT_SEPARATOR,
): DeviceShortcuts {
if (s.isBlank()) return DeviceShortcuts(emptyList())
val list = s.splitToSequence(separator)
.mapNotNull { DeviceShortcut.unmarshalFrom(it) }
.toList()
return DeviceShortcuts(list)
}
}
private fun getIndex(id: String) = devices.indexOfFirst { it.id == id }
private fun getIndex(host: String, port: Int) = devices.indexOfFirst {
it.host == host && it.port == port
}
fun get(id: String) = devices.firstOrNull { it.id == id }
fun get(host: String, port: Int) = devices.firstOrNull {
it.host == host && it.port == port
}
fun update(
id: String? = null,
host: String? = null,
port: Int? = null,
name: String? = null,
newPort: Int? = null,
updateNameOnlyWhenEmpty: Boolean = false,
): DeviceShortcuts {
val idx = if (id != null) getIndex(id)
else if (host != null && port != null) getIndex(host, port)
else -1
if (idx < 0) return this
val old = devices[idx]
val updateById = id != null
// 确定最终的属性值
val finalName = when {
name == null -> old.name
updateNameOnlyWhenEmpty && old.name.isNotBlank() -> old.name
else -> name
}
val finalHost = if (updateById) host ?: old.host else old.host
val finalPort = if (updateById) port ?: old.port else newPort ?: old.port
// 若无任何变化,返回原实例
if (finalName == old.name && finalHost == old.host && finalPort == old.port)
return this
val newList = devices.toMutableList().apply {
this[idx] = DeviceShortcut(
name = finalName,
host = finalHost,
port = finalPort,
)
}
return DeviceShortcuts(
if ((updateById && (finalHost != old.host || finalPort != old.port))
|| (newPort != null && newPort != old.port)
)
newList.distinctBy { it.id }
else newList
)
}
fun upsert(
shortcut: DeviceShortcut,
index: Int? = null,
): DeviceShortcuts {
val existingIdx = getIndex(shortcut.id)
val newList = devices.toMutableList()
if (existingIdx >= 0) {
newList[existingIdx] = shortcut
} else {
if (index != null) newList.add(index, shortcut)
else newList.add(shortcut)
}
return DeviceShortcuts(newList)
}
fun move(fromIndex: Int, toIndex: Int): DeviceShortcuts {
if (fromIndex !in devices.indices || toIndex !in devices.indices) return this
if (fromIndex == toIndex) return this
val mutable = devices.toMutableList()
val item = mutable.removeAt(fromIndex)
// 如果目标位置在原位置之后移除后列表长度减1因此目标索引需减1
val target = if (toIndex > fromIndex) toIndex - 1 else toIndex
mutable.add(target, item)
return DeviceShortcuts(mutable)
}
// 删除指定设备
fun remove(id: String) = DeviceShortcuts(devices.filterNot { it.id == id })
// 清空所有设备
fun clear() = DeviceShortcuts(emptyList())
// 复制当前实例
fun copy(devices: List<DeviceShortcut> = this.devices): DeviceShortcuts =
DeviceShortcuts(devices)
}
data class DeviceShortcut(
val name: String = "",
val host: String,
val port: Int,
val port: Int = Defaults.ADB_PORT,
) {
val id: String get() = "$host:$port"
fun marshalToString(
separator: String = DEFAULT_SEPARATOR,
): String = listOf(
name.trim(), host.trim(), port.toString()
).joinToString(
separator = separator
)
/**
* A compact shortcut entry for quick-connect lists shown in the UI.
* `online` indicates whether the device was reachable when last probed.
*/
internal data class DeviceShortcut(
val id: String,
val name: String,
val host: String,
val port: Int,
val online: Boolean,
companion object {
const val DEFAULT_SEPARATOR = "|"
fun unmarshalFrom(
s: String,
separator: String = DEFAULT_SEPARATOR,
): DeviceShortcut? {
val parts = s.split(separator, limit = 3)
return when (parts.size) {
3 -> {
val name = parts[0].trim()
val host = parts[1].trim()
val port = parts[2].trim().toIntOrNull() ?: Defaults.ADB_PORT
if (host.isNotBlank()) DeviceShortcut(
name = name,
host = host,
port = port,
)
else null
}
else -> null
}
}
}
}
data class ConnectionTarget(
val host: String,
val port: Int = Defaults.ADB_PORT,
) {
override fun toString(): String = "$host:$port"
companion object {
fun unmarshalFrom(s: String): ConnectionTarget? {
val parts = s.split(":", limit = 2)
return when (parts.size) {
2 -> ConnectionTarget(
host = parts[0].trim(),
port = parts[1].trim().toIntOrNull() ?: Defaults.ADB_PORT,
)
1 -> ConnectionTarget(
host = parts[0].trim(),
port = Defaults.ADB_PORT,
)
0 -> ConnectionTarget(
host = s.trim(),
port = Defaults.ADB_PORT,
)
else -> null
}
}
}
}

View File

@@ -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 {
// [<width>x<height>][/<dpi>]
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()
}
}
}
}

View File

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

View File

@@ -4,9 +4,9 @@ import android.content.Context
import android.os.Build
import android.util.Base64
import android.util.Log
import androidx.core.content.edit
import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults
import io.github.miuzarte.scrcpyforandroid.constants.AppPreferenceKeys
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
import io.github.miuzarte.scrcpyforandroid.storage.Storage
import kotlinx.coroutines.runBlocking
import java.io.BufferedInputStream
import java.io.Closeable
import java.io.EOFException
@@ -45,13 +45,13 @@ import kotlin.concurrent.thread
*/
internal class DirectAdbTransport(private val context: Context) {
private val keys: Pair<PrivateKey, ByteArray> by lazy { loadOrCreate() }
private val keys: Pair<PrivateKey, ByteArray> by lazy { runBlocking { loadOrCreate() } }
val privateKey: PrivateKey get() = keys.first
val publicKeyX509: ByteArray get() = keys.second
@Volatile
var keyName: String = AppDefaults.ADB_KEY_NAME
var keyName: String = AppSettings.ADB_KEY_NAME.defaultValue
fun connect(host: String, port: Int): DirectAdbConnection {
Log.i(TAG, "connect(): opening direct adbd transport to $host:$port")
@@ -60,7 +60,7 @@ internal class DirectAdbTransport(private val context: Context) {
port,
privateKey,
publicKeyX509,
keyName.ifBlank { AppDefaults.ADB_KEY_NAME })
keyName.ifBlank { AppSettings.ADB_KEY_NAME.defaultValue })
conn.handshake()
Log.i(TAG, "connect(): handshake success for $host:$port")
return conn
@@ -78,7 +78,7 @@ internal class DirectAdbTransport(private val context: Context) {
val pairingKey = AdbPairingKey(
privateKey = privateKey,
alias = keyName.ifBlank { AppDefaults.ADB_KEY_NAME },
alias = keyName.ifBlank { AppSettings.ADB_KEY_NAME.defaultValue },
)
return DirectAdbPairingClient(targetHost, port, targetCode, pairingKey).use {
it.start()
@@ -102,39 +102,52 @@ internal class DirectAdbTransport(private val context: Context) {
}
/**
* Load persisted RSA keypair from shared preferences, or generate a new one.
* Load persisted RSA keypair from DataStore, or generate a new one.
* Returns (privateKey, publicX509Bytes).
*/
private fun loadOrCreate(): Pair<PrivateKey, ByteArray> {
val prefs = context.getSharedPreferences(
AppPreferenceKeys.NATIVE_ADB_KEY_PREFS_NAME,
Context.MODE_PRIVATE
)
val privB64 = prefs.getString(AppPreferenceKeys.NATIVE_ADB_PRIVATE_KEY, null)
if (privB64 != null) {
private suspend fun loadOrCreate(
forceNew: Boolean = false,
): Pair<PrivateKey, ByteArray> {
val adbClientData = Storage.adbClientData
val privB64 = adbClientData.rsaPrivateKey.get()
if (privB64.isNotBlank() && !forceNew) {
try {
val kf = KeyFactory.getInstance("RSA")
val priv =
kf.generatePrivate(PKCS8EncodedKeySpec(Base64.decode(privB64, Base64.DEFAULT)))
val priv = kf.generatePrivate(
PKCS8EncodedKeySpec(
Base64.decode(privB64, Base64.DEFAULT)
)
)
val pub = derivePublicX509(priv)
Log.i(TAG, "loadOrCreate(): loaded persisted RSA key pair, fp=${fingerprint(pub)}")
Log.i(
TAG,
"loadOrCreate(): loaded persisted RSA key pair from DataStore, " +
"fp=${fingerprint(pub)}"
)
return Pair(priv, pub)
} catch (e: Exception) {
Log.w(TAG, "loadOrCreate(): failed to load persisted key, regenerating", e)
Log.w(
TAG,
"loadOrCreate(): failed to load persisted key from DataStore, regenerating",
e
)
}
}
val kpg = KeyPairGenerator.getInstance("RSA")
kpg.initialize(2048)
val kp = kpg.generateKeyPair()
prefs.edit {
putString(
AppPreferenceKeys.NATIVE_ADB_PRIVATE_KEY,
Base64.encodeToString(kp.private.encoded, Base64.NO_WRAP)
)
}
// 保存到 DataStore
val privateKeyB64 = Base64.encodeToString(kp.private.encoded, Base64.NO_WRAP)
val publicKeyB64 = Base64.encodeToString(kp.public.encoded, Base64.NO_WRAP)
adbClientData.rsaPrivateKey.set(privateKeyB64)
adbClientData.rsaPublicKeyX509.set(publicKeyB64)
Log.i(
TAG,
"loadOrCreate(): generated new RSA key pair, fp=${fingerprint(kp.public.encoded)}"
"loadOrCreate(): generated new RSA key pair and saved to DataStore, fp=${fingerprint(kp.public.encoded)}"
)
return Pair(kp.private, kp.public.encoded)
}
@@ -203,7 +216,7 @@ internal class DirectAdbConnection(
val port: Int,
private val privateKey: PrivateKey,
private val publicKeyX509: ByteArray,
private val keyName: String = AppDefaults.ADB_KEY_NAME,
private val keyName: String = AppSettings.ADB_KEY_NAME.defaultValue,
) : AutoCloseable {
private val sha1DigestInfoPrefix = byteArrayOf(
@@ -380,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) {
@@ -548,7 +566,7 @@ internal class DirectAdbConnection(
* Logical ADB stream abstraction mapped to a local id. Provides blocking
* `InputStream`/`OutputStream` implementations and lifecycle helpers used by callers.
*/
internal class AdbSocketStream(
class AdbSocketStream(
val localId: Int,
private val sender: (cmd: Int, arg0: Int, arg1: Int, data: ByteArray) -> Unit,
) : Closeable {

View File

@@ -143,7 +143,7 @@ internal class DirectAdbPairingClient(
READY,
EXCHANGING_MSGS,
EXCHANGING_PEER_INFO,
STOPPED,
STOPPED;
}
private lateinit var socket: Socket

View File

@@ -2,18 +2,26 @@ 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
/**
* Higher-level ADB service that wraps `DirectAdbTransport` and provides
* synchronized connect/disconnect/shell helpers for callers.
* coroutine-based connect/disconnect/shell helpers for callers.
*
* Methods are synchronized because the underlying transport is single-connection
* and accessed from the app's serialized IO executor.
* Methods use Mutex for thread-safety because the underlying transport is single-connection
* and may be accessed from multiple coroutines.
*
* All network operations are executed on Dispatchers.IO.
*/
class NativeAdbService(appContext: Context) {
private val transport = DirectAdbTransport(appContext)
private val mutex = Mutex()
@Volatile
private var connection: DirectAdbConnection? = null
@@ -30,14 +38,13 @@ class NativeAdbService(appContext: Context) {
transport.keyName = value
}
@Synchronized
fun pair(host: String, port: Int, pairingCode: String): Boolean {
suspend fun pair(host: String, port: Int, pairingCode: String): Boolean = mutex.withLock {
val h = host.trim()
val code = pairingCode.trim()
require(h.isNotBlank()) { "host is blank" }
require(code.isNotBlank()) { "pairing code is blank" }
Log.i(TAG, "pair(): host=$h port=$port")
return try {
return@withLock try {
transport.pair(h, port, code)
} catch (e: Exception) {
Log.e(TAG, "pair(): failed host=$h port=$port", e)
@@ -46,12 +53,11 @@ class NativeAdbService(appContext: Context) {
}
}
@Synchronized
fun discoverPairingService(
suspend fun discoverPairingService(
timeoutMs: Long = 12_000,
includeLanDevices: Boolean = true
): Pair<String, Int>? {
return try {
): Pair<String, Int>? = mutex.withLock {
return@withLock try {
transport.discoverPairingService(timeoutMs, includeLanDevices)
} catch (e: Exception) {
Log.w(TAG, "discoverPairingService(): failed", e)
@@ -59,12 +65,11 @@ class NativeAdbService(appContext: Context) {
}
}
@Synchronized
fun discoverConnectService(
suspend fun discoverConnectService(
timeoutMs: Long = 12_000,
includeLanDevices: Boolean = true
): Pair<String, Int>? {
return try {
): Pair<String, Int>? = mutex.withLock {
return@withLock try {
transport.discoverConnectService(timeoutMs, includeLanDevices)
} catch (e: Exception) {
Log.w(TAG, "discoverConnectService(): failed", e)
@@ -77,63 +82,81 @@ class NativeAdbService(appContext: Context) {
* same host:port it is reused; otherwise the previous connection is closed
* before attempting the new connect.
*/
@Synchronized
fun connect(host: String, port: Int): Boolean {
suspend fun connect(
host: String,
port: Int,
timeout: Duration = Duration.INFINITE,
) = withContext(Dispatchers.IO) {
mutex.withLock {
Log.i(TAG, "connect(): host=$host port=$port")
val existing = connection
if (existing != null && existing.isAlive() && connectedHost == host && connectedPort == port) {
return true
if (connection != null
&& connection!!.isAlive()
&& connectedHost == host
&& connectedPort == port
) {
return@withLock
}
disconnect()
disconnectInternal()
try {
val conn = transport.connect(host, port)
val conn = withTimeout(timeout) { transport.connect(host, port) }
connection = conn
connectedHost = host
connectedPort = port
return true
} catch (e: Exception) {
Log.e(TAG, "connect(): failed host=$host port=$port", e)
val detail = e.message ?: "${e.javaClass.simpleName} (no message)"
throw IllegalStateException("ADB connect failed to $host:$port -> $detail", e)
}
}
}
/**
* Close the current ADB connection immediately.
*/
@Synchronized
fun disconnect() {
suspend fun disconnect() = withContext(Dispatchers.IO) {
mutex.withLock {
disconnectInternal()
}
}
suspend fun isConnected(): Boolean = mutex.withLock {
connection?.isAlive() == true
}
/**
* Execute a shell command on the connected device and return stdout text.
*/
suspend fun shell(command: String): String = mutex.withLock {
val response = requireConnection().shell(command)
Log.d(TAG, "command: $command, response: $response")
response
}
suspend fun openShellStream(command: String): AdbSocketStream = mutex.withLock {
requireConnection().openStream("shell:$command")
}
suspend fun push(localPath: Path, remotePath: String) = mutex.withLock {
requireConnection().push(localPath.toFile().readBytes(), remotePath)
}
suspend fun openAbstractSocket(name: String): AdbSocketStream = mutex.withLock {
requireConnection().openStream("localabstract:$name")
}
suspend fun close() {
disconnect()
}
private fun disconnectInternal() {
runCatching { connection?.close() }
connection = null
connectedHost = null
connectedPort = null
}
@Synchronized
fun isConnected(): Boolean = connection?.isAlive() == true
/**
* Execute a shell command on the connected device and return stdout text.
*/
@Synchronized
fun shell(command: String): String = requireConnection().shell(command)
@Synchronized
internal fun openShellStream(command: String): AdbSocketStream =
requireConnection().openStream("shell:$command")
@Synchronized
fun push(localPath: Path, remotePath: String) {
requireConnection().push(localPath.toFile().readBytes(), remotePath)
}
@Synchronized
internal fun openAbstractSocket(name: String): AdbSocketStream =
requireConnection().openStream("localabstract:$name")
@Synchronized
fun close() = disconnect()
private fun requireConnection(): DirectAdbConnection {
return connection?.takeIf { it.isAlive() }
?: throw IllegalStateException("ADB not connected")

View File

@@ -0,0 +1,359 @@
package io.github.miuzarte.scrcpyforandroid.nativecore
import android.graphics.SurfaceTexture
import android.opengl.EGL14
import android.opengl.EGLConfig
import android.opengl.EGLContext
import android.opengl.EGLDisplay
import android.opengl.EGLSurface
import android.opengl.GLES11Ext
import android.opengl.GLES20
import android.opengl.Matrix
import android.os.Handler
import android.os.HandlerThread
import android.util.Log
import android.view.Surface
import java.util.concurrent.atomic.AtomicLong
/**
* Decoder always renders into a persistent SurfaceTexture-backed Surface.
* UI surfaces are display-only targets fed from that persistent texture via EGL.
*/
class PersistentVideoRenderer {
private val tag = "PersistentVideoRenderer"
private val renderThread = HandlerThread("PersistentVideoRenderer").apply { start() }
private val handler = Handler(renderThread.looper)
@Volatile
private var initialized = false
@Volatile
private var released = false
private var eglDisplay: EGLDisplay = EGL14.EGL_NO_DISPLAY
private var eglContext: EGLContext = EGL14.EGL_NO_CONTEXT
private var eglConfig: EGLConfig? = null
private var eglPbufferSurface: EGLSurface = EGL14.EGL_NO_SURFACE
private var displayEglSurface: EGLSurface = EGL14.EGL_NO_SURFACE
private var displaySurface: Surface? = null
private var displaySurfaceId: Int? = null
private var oesTextureId = 0
private var decoderSurfaceTexture: SurfaceTexture? = null
private var decoderSurface: Surface? = null
private val stMatrix = FloatArray(16)
private val mvpMatrix = FloatArray(16)
private val frameAvailableCount = AtomicLong(0)
private val frameConsumedCount = AtomicLong(0)
private val frameRenderedCount = AtomicLong(0)
private var program = 0
private var positionHandle = 0
private var texCoordHandle = 0
private var mvpMatrixHandle = 0
private var stMatrixHandle = 0
private var samplerHandle = 0
private val initLock = Object()
fun getDecoderSurface(): Surface {
ensureInitialized()
synchronized(initLock) {
return requireNotNull(decoderSurface) { "decoderSurface not initialized" }
}
}
fun attachDisplaySurface(surface: Surface) {
ensureInitialized()
val newId = System.identityHashCode(surface)
if (displaySurfaceId == newId) return
Log.i(tag, "attachDisplaySurface(): request surfaceId=$newId old=${displaySurfaceId}")
handler.post {
if (released || !surface.isValid) return@post
releaseDisplaySurfaceLocked()
displaySurface = surface
displaySurfaceId = newId
displayEglSurface = EGL14.eglCreateWindowSurface(
eglDisplay,
eglConfig,
surface,
intArrayOf(EGL14.EGL_NONE),
0
)
Log.i(tag, "attachDisplaySurface(): attached surfaceId=$newId")
drawFrame()
}
}
fun detachDisplaySurface(surface: Surface? = null, releaseSurface: Boolean = false) {
val requestId = surface?.let { System.identityHashCode(it) }
Log.i(tag, "detachDisplaySurface(): request surfaceId=$requestId releaseSurface=$releaseSurface current=${displaySurfaceId}")
handler.post {
if (released) return@post
if (requestId != null && requestId != displaySurfaceId) return@post
releaseDisplaySurfaceLocked()
if (releaseSurface) {
runCatching { surface?.release() }
}
}
}
fun release() {
handler.post {
if (released) return@post
released = true
releaseDisplaySurfaceLocked()
runCatching { decoderSurface?.release() }
decoderSurface = null
runCatching { decoderSurfaceTexture?.release() }
decoderSurfaceTexture = null
if (program != 0) {
GLES20.glDeleteProgram(program)
program = 0
}
if (oesTextureId != 0) {
GLES20.glDeleteTextures(1, intArrayOf(oesTextureId), 0)
oesTextureId = 0
}
if (eglDisplay !== EGL14.EGL_NO_DISPLAY) {
EGL14.eglMakeCurrent(
eglDisplay,
EGL14.EGL_NO_SURFACE,
EGL14.EGL_NO_SURFACE,
EGL14.EGL_NO_CONTEXT
)
if (eglPbufferSurface != EGL14.EGL_NO_SURFACE) {
EGL14.eglDestroySurface(eglDisplay, eglPbufferSurface)
}
EGL14.eglDestroyContext(eglDisplay, eglContext)
EGL14.eglTerminate(eglDisplay)
}
eglDisplay = EGL14.EGL_NO_DISPLAY
eglContext = EGL14.EGL_NO_CONTEXT
eglPbufferSurface = EGL14.EGL_NO_SURFACE
eglConfig = null
renderThread.quitSafely()
}
}
private fun ensureInitialized() {
if (initialized) return
synchronized(initLock) {
if (initialized) return
val latch = java.util.concurrent.CountDownLatch(1)
handler.post {
initializeLocked()
initialized = true
latch.countDown()
}
latch.await()
}
}
private fun initializeLocked() {
eglDisplay = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY)
check(eglDisplay != EGL14.EGL_NO_DISPLAY)
val version = IntArray(2)
check(EGL14.eglInitialize(eglDisplay, version, 0, version, 1))
val configs = arrayOfNulls<EGLConfig>(1)
val numConfigs = IntArray(1)
val attribs = intArrayOf(
EGL14.EGL_RENDERABLE_TYPE, 4,
EGL14.EGL_RED_SIZE, 8,
EGL14.EGL_GREEN_SIZE, 8,
EGL14.EGL_BLUE_SIZE, 8,
EGL14.EGL_ALPHA_SIZE, 8,
EGL14.EGL_NONE
)
check(EGL14.eglChooseConfig(eglDisplay, attribs, 0, configs, 0, 1, numConfigs, 0))
eglConfig = configs[0]
eglContext = EGL14.eglCreateContext(
eglDisplay,
eglConfig,
EGL14.EGL_NO_CONTEXT,
intArrayOf(0x3098, 2, EGL14.EGL_NONE),
0
)
check(eglContext != EGL14.EGL_NO_CONTEXT)
eglPbufferSurface = EGL14.eglCreatePbufferSurface(
eglDisplay,
eglConfig,
intArrayOf(EGL14.EGL_WIDTH, 1, EGL14.EGL_HEIGHT, 1, EGL14.EGL_NONE),
0
)
check(eglPbufferSurface != EGL14.EGL_NO_SURFACE)
check(EGL14.eglMakeCurrent(eglDisplay, eglPbufferSurface, eglPbufferSurface, eglContext))
oesTextureId = createExternalTexture()
decoderSurfaceTexture = SurfaceTexture(oesTextureId).apply {
setOnFrameAvailableListener({
val n = frameAvailableCount.incrementAndGet()
if (n == 1L || n % 120L == 0L) {
Log.i(tag, "onFrameAvailable(): available=$n consumed=${frameConsumedCount.get()} rendered=${frameRenderedCount.get()} display=${displaySurfaceId != null}")
}
drawFrame()
}, handler)
}
decoderSurface = Surface(decoderSurfaceTexture)
Log.i(tag, "initializeLocked(): decoder surface created")
Matrix.setIdentityM(stMatrix, 0)
Matrix.setIdentityM(mvpMatrix, 0)
Matrix.rotateM(mvpMatrix, 0, 180f, 0f, 0f, 1f)
Matrix.scaleM(mvpMatrix, 0, -1f, 1f, 1f)
program = createProgram(VERTEX_SHADER, FRAGMENT_SHADER)
positionHandle = GLES20.glGetAttribLocation(program, "aPosition")
texCoordHandle = GLES20.glGetAttribLocation(program, "aTexCoord")
mvpMatrixHandle = GLES20.glGetUniformLocation(program, "uMvpMatrix")
stMatrixHandle = GLES20.glGetUniformLocation(program, "uStMatrix")
samplerHandle = GLES20.glGetUniformLocation(program, "sTexture")
}
private fun drawFrame() {
if (released) return
val surfaceTexture = decoderSurfaceTexture ?: return
if (eglPbufferSurface == EGL14.EGL_NO_SURFACE) return
// Always consume decoder frames on the persistent context, even if there is no
// visible output surface right now. Otherwise the producer side can stall.
EGL14.eglMakeCurrent(eglDisplay, eglPbufferSurface, eglPbufferSurface, eglContext)
runCatching { surfaceTexture.updateTexImage() }
.onSuccess {
val consumed = frameConsumedCount.incrementAndGet()
if (consumed == 1L || consumed % 120L == 0L) {
Log.i(tag, "drawFrame(): consumed=$consumed available=${frameAvailableCount.get()} rendered=${frameRenderedCount.get()} display=${displaySurfaceId != null}")
}
}
.onFailure { Log.w(tag, "updateTexImage failed", it) }
surfaceTexture.getTransformMatrix(stMatrix)
if (displayEglSurface == EGL14.EGL_NO_SURFACE) {
return
}
val width = IntArray(1)
val height = IntArray(1)
EGL14.eglQuerySurface(eglDisplay, displayEglSurface, EGL14.EGL_WIDTH, width, 0)
EGL14.eglQuerySurface(eglDisplay, displayEglSurface, EGL14.EGL_HEIGHT, height, 0)
EGL14.eglMakeCurrent(eglDisplay, displayEglSurface, displayEglSurface, eglContext)
GLES20.glViewport(0, 0, width[0].coerceAtLeast(1), height[0].coerceAtLeast(1))
GLES20.glClearColor(0f, 0f, 0f, 1f)
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
GLES20.glUseProgram(program)
GLES20.glUniformMatrix4fv(mvpMatrixHandle, 1, false, mvpMatrix, 0)
GLES20.glUniformMatrix4fv(stMatrixHandle, 1, false, stMatrix, 0)
GLES20.glUniform1i(samplerHandle, 0)
VERTICES.position(0)
GLES20.glVertexAttribPointer(positionHandle, 2, GLES20.GL_FLOAT, false, 16, VERTICES)
GLES20.glEnableVertexAttribArray(positionHandle)
VERTICES.position(2)
GLES20.glVertexAttribPointer(texCoordHandle, 2, GLES20.GL_FLOAT, false, 16, VERTICES)
GLES20.glEnableVertexAttribArray(texCoordHandle)
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, oesTextureId)
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
EGL14.eglSwapBuffers(eglDisplay, displayEglSurface)
val rendered = frameRenderedCount.incrementAndGet()
if (rendered == 1L || rendered % 120L == 0L) {
Log.i(tag, "drawFrame(): rendered=$rendered consumed=${frameConsumedCount.get()} available=${frameAvailableCount.get()} viewport=${width[0]}x${height[0]}")
}
}
private fun releaseDisplaySurfaceLocked() {
if (displayEglSurface != EGL14.EGL_NO_SURFACE) {
EGL14.eglDestroySurface(eglDisplay, displayEglSurface)
displayEglSurface = EGL14.EGL_NO_SURFACE
}
if (displaySurfaceId != null) {
Log.i(tag, "releaseDisplaySurfaceLocked(): surfaceId=$displaySurfaceId")
}
displaySurface = null
displaySurfaceId = null
}
private fun createExternalTexture(): Int {
val textures = IntArray(1)
GLES20.glGenTextures(1, textures, 0)
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textures[0])
GLES20.glTexParameteri(
GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
GLES20.GL_TEXTURE_MIN_FILTER,
GLES20.GL_LINEAR
)
GLES20.glTexParameteri(
GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
GLES20.GL_TEXTURE_MAG_FILTER,
GLES20.GL_LINEAR
)
GLES20.glTexParameteri(
GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
GLES20.GL_TEXTURE_WRAP_S,
GLES20.GL_CLAMP_TO_EDGE
)
GLES20.glTexParameteri(
GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
GLES20.GL_TEXTURE_WRAP_T,
GLES20.GL_CLAMP_TO_EDGE
)
return textures[0]
}
private fun createProgram(vertexShader: String, fragmentShader: String): Int {
val vertex = compileShader(GLES20.GL_VERTEX_SHADER, vertexShader)
val fragment = compileShader(GLES20.GL_FRAGMENT_SHADER, fragmentShader)
return GLES20.glCreateProgram().also { program ->
GLES20.glAttachShader(program, vertex)
GLES20.glAttachShader(program, fragment)
GLES20.glLinkProgram(program)
}
}
private fun compileShader(type: Int, source: String): Int {
return GLES20.glCreateShader(type).also { shader ->
GLES20.glShaderSource(shader, source)
GLES20.glCompileShader(shader)
}
}
companion object {
private val VERTICES = java.nio.ByteBuffer.allocateDirect(4 * 4 * 4)
.order(java.nio.ByteOrder.nativeOrder())
.asFloatBuffer()
.apply {
put(
floatArrayOf(
-1f, -1f, 0f, 1f,
1f, -1f, 1f, 1f,
-1f, 1f, 0f, 0f,
1f, 1f, 1f, 0f,
)
)
position(0)
}
private const val VERTEX_SHADER = """
attribute vec4 aPosition;
attribute vec4 aTexCoord;
uniform mat4 uMvpMatrix;
uniform mat4 uStMatrix;
varying vec2 vTexCoord;
void main() {
gl_Position = uMvpMatrix * aPosition;
vTexCoord = (uStMatrix * aTexCoord).xy;
}
"""
private const val FRAGMENT_SHADER = """
#extension GL_OES_EGL_image_external : require
precision mediump float;
varying vec2 vTexCoord;
uniform samplerExternalOES sTexture;
void main() {
gl_FragColor = texture2D(sTexture, vTexCoord);
}
"""
}
}

View File

@@ -9,6 +9,7 @@ import android.media.AudioTrack
import android.media.MediaCodec
import android.media.MediaFormat
import android.util.Log
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
import java.nio.ByteBuffer
import java.nio.ByteOrder
@@ -42,9 +43,9 @@ class ScrcpyAudioPlayer(private val codecId: Int) {
}"
)
when (codecId) {
AUDIO_CODEC_OPUS -> prepareOpus(data)
AUDIO_CODEC_AAC -> prepareAac(data)
AUDIO_CODEC_FLAC -> prepareFlac(data)
Codec.OPUS.id -> prepareOpus(data)
Codec.AAC.id -> prepareAac(data)
Codec.FLAC.id -> prepareFlac(data)
// RAW has no config packet
}
return
@@ -55,7 +56,7 @@ class ScrcpyAudioPlayer(private val codecId: Int) {
Log.i(TAG, "feedPacket(): packets=$packetCount prepared=$prepared size=${data.size}")
}
if (codecId == AUDIO_CODEC_RAW) {
if (codecId == Codec.RAW.id) {
ensureRawAudioTrack()?.write(data, 0, data.size, AudioTrack.WRITE_NON_BLOCKING)
return
}
@@ -212,10 +213,6 @@ class ScrcpyAudioPlayer(private val codecId: Int) {
companion object {
private const val TAG = "ScrcpyAudioPlayer"
const val AUDIO_CODEC_OPUS = 0x6f707573
const val AUDIO_CODEC_AAC = 0x00616163
const val AUDIO_CODEC_FLAC = 0x666c6163
const val AUDIO_CODEC_RAW = 0x00726177
private const val SAMPLE_RATE = 48000
private const val CHANNELS = 2
private const val CODEC_TIMEOUT_US = 10_000L

View File

@@ -1,684 +0,0 @@
package io.github.miuzarte.scrcpyforandroid.pages
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
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.scaffolds.AppPageLazyColumn
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlide
import kotlinx.coroutines.launch
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.ScrollBehavior
import top.yukonga.miuix.kmp.basic.SnackbarHostState
import top.yukonga.miuix.kmp.basic.SpinnerEntry
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextField
import top.yukonga.miuix.kmp.extra.SuperArrow
import top.yukonga.miuix.kmp.extra.SuperDropdown
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 "自定义",
)
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,
sessionStarted: Boolean,
audioEnabled: Boolean,
noControl: Boolean,
onNoControlChange: (Boolean) -> Unit,
audioDup: Boolean,
onAudioDupChange: (Boolean) -> Unit,
audioSourcePreset: String,
onAudioSourcePresetChange: (String) -> Unit,
audioSourceCustom: String,
onAudioSourceCustomChange: (String) -> Unit,
videoSourcePreset: String,
onVideoSourcePresetChange: (String) -> Unit,
cameraIdInput: String,
onCameraIdInputChange: (String) -> Unit,
cameraFacingPreset: String,
onCameraFacingPresetChange: (String) -> Unit,
cameraSizePreset: String,
onCameraSizePresetChange: (String) -> Unit,
cameraSizeCustom: String,
onCameraSizeCustomChange: (String) -> Unit,
cameraSizeDropdownItems: List<String>,
cameraSizeIndex: Int,
cameraArInput: String,
onCameraArInputChange: (String) -> Unit,
cameraFpsInput: String,
onCameraFpsInputChange: (String) -> Unit,
cameraHighSpeed: Boolean,
onCameraHighSpeedChange: (Boolean) -> Unit,
noAudioPlayback: Boolean,
onNoAudioPlaybackChange: (Boolean) -> Unit,
noVideo: Boolean,
onNoVideoChange: (Boolean) -> Unit,
requireAudio: Boolean,
onRequireAudioChange: (Boolean) -> Unit,
turnScreenOff: Boolean,
onTurnScreenOffChange: (Boolean) -> Unit,
maxSizeInput: String,
onMaxSizeInputChange: (String) -> Unit,
maxFpsInput: String,
onMaxFpsInputChange: (String) -> Unit,
videoEncoderDropdownItems: List<String>,
videoEncoderTypeMap: Map<String, String>,
videoEncoderIndex: Int,
onVideoEncoderChange: (String) -> Unit,
videoCodecOptions: String,
onVideoCodecOptionsChange: (String) -> Unit,
audioEncoderDropdownItems: List<String>,
audioEncoderTypeMap: Map<String, String>,
audioEncoderIndex: Int,
onAudioEncoderChange: (String) -> Unit,
audioCodecOptions: String,
onAudioCodecOptionsChange: (String) -> Unit,
onRefreshEncoders: () -> Unit,
onRefreshCameraSizes: () -> Unit,
newDisplayWidth: String,
onNewDisplayWidthChange: (String) -> Unit,
newDisplayHeight: String,
onNewDisplayHeightChange: (String) -> Unit,
newDisplayDpi: String,
onNewDisplayDpiChange: (String) -> Unit,
displayIdInput: String,
onDisplayIdInputChange: (String) -> Unit,
cropWidth: String,
onCropWidthChange: (String) -> Unit,
cropHeight: String,
onCropHeightChange: (String) -> Unit,
cropX: String,
onCropXChange: (String) -> Unit,
cropY: String,
onCropYChange: (String) -> Unit,
) {
val focusManager = LocalFocusManager.current
val scope = rememberCoroutineScope()
val maxSizePresetIndex =
presetIndexFromInputForAdvancedPage(maxSizeInput, ScrcpyPresets.MaxSize)
val maxFpsPresetIndex = presetIndexFromInputForAdvancedPage(maxFpsInput, ScrcpyPresets.MaxFPS)
val audioSourceItems = AUDIO_SOURCE_OPTIONS.map { it.second }
val audioSourceIndex = AUDIO_SOURCE_OPTIONS.indexOfFirst { it.first == audioSourcePreset }
.let { if (it >= 0) it else 0 }
val videoSourceItems = VIDEO_SOURCE_OPTIONS.map { it.second }
val videoSourceIndex = VIDEO_SOURCE_OPTIONS.indexOfFirst { it.first == videoSourcePreset }
.let { if (it >= 0) it else 0 }
val cameraFacingItems = CAMERA_FACING_OPTIONS.map { it.second }
val cameraFacingIndex = CAMERA_FACING_OPTIONS.indexOfFirst { it.first == cameraFacingPreset }
.let { if (it >= 0) it else 0 }
val cameraFpsPresetIndex =
presetIndexFromInputForAdvancedPage(cameraFpsInput, CAMERA_FPS_PRESETS)
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 },
)
}
}
// 高级参数
AppPageLazyColumn(
contentPadding = contentPadding,
scrollBehavior = scrollBehavior,
) {
item {
Card {
SuperSwitch(
title = "启动后关闭屏幕",
summary = "--turn-screen-off",
checked = turnScreenOff,
onCheckedChange = { value ->
onTurnScreenOffChange(value)
if (value) scope.launch {
// github.com/Genymobile/scrcpy/issues/3376
// github.com/Genymobile/scrcpy/issues/4587
// github.com/Genymobile/scrcpy/issues/5676
snackbarHostState.showSnackbar("注意:大部分设备在关闭屏幕后刷新率会降低/减半")
}
},
enabled = !sessionStarted && !noControl,
)
SuperSwitch(
title = "禁用控制",
summary = "--no-control",
checked = noControl,
onCheckedChange = onNoControlChange,
enabled = !sessionStarted,
)
SuperSwitch(
title = "禁用视频",
summary = "--no-video",
checked = noVideo,
onCheckedChange = onNoVideoChange,
enabled = !sessionStarted,
)
}
}
item {
Card {
SuperDropdown(
title = "视频来源",
summary = "--video-source",
items = videoSourceItems,
selectedIndex = videoSourceIndex,
onSelectedIndexChange = { index ->
onVideoSourcePresetChange(VIDEO_SOURCE_OPTIONS[index].first)
},
enabled = !sessionStarted,
)
if (videoSourcePreset == "display") {
TextField(
value = displayIdInput,
onValueChange = onDisplayIdInputChange,
label = "--display-id",
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = UiSpacing.CardContent)
.padding(bottom = UiSpacing.CardContent),
)
}
if (videoSourcePreset == "camera") {
TextField(
value = cameraIdInput,
onValueChange = onCameraIdInputChange,
label = "--camera-id",
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = UiSpacing.CardContent)
.padding(bottom = UiSpacing.CardContent),
)
SuperArrow(
title = "重新获取 Camera Sizes",
summary = "--list-camera-sizes",
onClick = onRefreshCameraSizes,
enabled = !sessionStarted,
)
SuperDropdown(
title = "摄像头朝向",
summary = "--camera-facing",
items = cameraFacingItems,
selectedIndex = cameraFacingIndex,
onSelectedIndexChange = { index ->
onCameraFacingPresetChange(CAMERA_FACING_OPTIONS[index].first)
},
enabled = !sessionStarted,
)
SuperDropdown(
title = "摄像头分辨率",
summary = "--camera-size",
items = cameraSizeDropdownItems,
selectedIndex = cameraSizeIndex.coerceIn(
0,
(cameraSizeDropdownItems.size - 1).coerceAtLeast(0)
),
onSelectedIndexChange = { index ->
onCameraSizePresetChange(
when (index) {
0 -> ""
cameraSizeDropdownItems.lastIndex -> "custom"
else -> cameraSizeDropdownItems[index]
},
)
},
enabled = !sessionStarted,
)
if (cameraSizePreset == "custom") {
TextField(
value = cameraSizeCustom,
onValueChange = onCameraSizeCustomChange,
label = "--camera-size",
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = UiSpacing.CardContent)
.padding(bottom = UiSpacing.CardContent),
)
}
TextField(
value = cameraArInput,
onValueChange = onCameraArInputChange,
label = "--camera-ar",
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = UiSpacing.CardContent)
.padding(bottom = UiSpacing.CardContent),
)
SuperSlide(
title = "摄像头帧率",
summary = "--camera-fps",
value = cameraFpsPresetIndex.toFloat(),
onValueChange = { value ->
val idx = value.roundToInt().coerceIn(0, CAMERA_FPS_PRESETS.lastIndex)
val preset = CAMERA_FPS_PRESETS[idx]
onCameraFpsInputChange(if (preset == 0) "" else preset.toString())
},
valueRange = 0f..CAMERA_FPS_PRESETS.lastIndex.toFloat(),
steps = (CAMERA_FPS_PRESETS.size - 2).coerceAtLeast(0),
enabled = !sessionStarted,
unit = "fps",
zeroStateText = "默认",
showUnitWhenZeroState = false,
showKeyPoints = true,
keyPoints = CAMERA_FPS_PRESETS.indices.map { it.toFloat() },
displayText = cameraFpsInput,
inputHint = "0 或留空表示默认",
inputInitialValue = cameraFpsInput,
inputFilter = { it.filter(Char::isDigit) },
inputValueRange = 0f..Float.MAX_VALUE,
onInputConfirm = {
val normalized = it.ifBlank { "" }
onCameraFpsInputChange(if (normalized == "0") "" else normalized)
},
)
SuperSwitch(
title = "高帧率模式",
summary = "--camera-high-speed",
checked = cameraHighSpeed,
onCheckedChange = onCameraHighSpeedChange,
enabled = !sessionStarted,
)
}
}
}
item {
Card {
SuperDropdown(
title = "音频来源",
summary = "--audio-source",
items = audioSourceItems,
selectedIndex = audioSourceIndex,
onSelectedIndexChange = { index ->
onAudioSourcePresetChange(AUDIO_SOURCE_OPTIONS[index].first)
},
enabled = !sessionStarted && audioEnabled,
)
if (audioSourcePreset == "custom") {
TextField(
value = audioSourceCustom,
onValueChange = onAudioSourceCustomChange,
label = "--audio-source",
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = UiSpacing.CardContent)
.padding(bottom = UiSpacing.CardContent),
)
}
SuperSwitch(
title = "音频双路输出",
summary = "--audio-dup",
checked = audioDup,
onCheckedChange = onAudioDupChange,
enabled = !sessionStarted && audioEnabled,
)
SuperSwitch(
title = "仅转发不播放",
summary = "--no-audio-playback",
checked = noAudioPlayback,
onCheckedChange = onNoAudioPlaybackChange,
enabled = !sessionStarted && audioEnabled,
)
SuperSwitch(
title = "音频失败时终止 [TODO]",
summary = "--require-audio",
checked = requireAudio,
onCheckedChange = onRequireAudioChange,
enabled = false,
)
}
}
item {
Card {
SuperSlide(
title = "最大分辨率",
summary = "--max-size",
value = maxSizePresetIndex.toFloat(),
onValueChange = { value ->
val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.MaxSize.lastIndex)
val preset = ScrcpyPresets.MaxSize[idx]
onMaxSizeInputChange(if (preset == 0) "" else preset.toString())
},
valueRange = 0f..ScrcpyPresets.MaxSize.lastIndex.toFloat(),
steps = (ScrcpyPresets.MaxSize.size - 2).coerceAtLeast(0),
enabled = !sessionStarted,
unit = "px",
zeroStateText = "关闭",
showUnitWhenZeroState = false,
showKeyPoints = true,
keyPoints = ScrcpyPresets.MaxSize.indices.map { it.toFloat() },
displayText = maxSizeInput,
inputHint = "0 或留空表示关闭",
inputInitialValue = maxSizeInput,
inputFilter = { it.filter(Char::isDigit) },
inputValueRange = 0f..Float.MAX_VALUE,
onInputConfirm = {
val normalized = it.ifBlank { "" }
onMaxSizeInputChange(normalized)
},
)
SuperSlide(
title = "最大帧率",
summary = "--max-fps",
value = maxFpsPresetIndex.toFloat(),
onValueChange = { value ->
val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.MaxFPS.lastIndex)
val preset = ScrcpyPresets.MaxFPS[idx]
onMaxFpsInputChange(if (preset == 0) "" else preset.toString())
},
valueRange = 0f..ScrcpyPresets.MaxFPS.lastIndex.toFloat(),
steps = (ScrcpyPresets.MaxFPS.size - 2).coerceAtLeast(0),
enabled = !sessionStarted,
unit = "fps",
zeroStateText = "关闭",
showUnitWhenZeroState = false,
showKeyPoints = true,
keyPoints = ScrcpyPresets.MaxFPS.indices.map { it.toFloat() },
displayText = maxFpsInput,
inputHint = "0 或留空表示关闭",
inputInitialValue = maxFpsInput,
inputFilter = { it.filter(Char::isDigit) },
inputValueRange = 0f..Float.MAX_VALUE,
onInputConfirm = {
val normalized = it.ifBlank { "" }
onMaxFpsInputChange(normalized)
},
)
}
}
item {
Card {
SuperArrow(
title = "重新获取编码器列表",
summary = "--list-encoders",
onClick = onRefreshEncoders,
enabled = !sessionStarted,
)
SuperSpinner(
title = "视频编码器",
summary = "--video-encoder",
items = videoEncoderEntries,
selectedIndex = videoEncoderIndex,
onSelectedIndexChange = { index ->
onVideoEncoderChange(if (index == 0) "" else videoEncoderDropdownItems[index])
},
enabled = !sessionStarted,
)
TextField(
value = videoCodecOptions,
onValueChange = onVideoCodecOptionsChange,
label = "--video-codec-options",
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = UiSpacing.CardContent)
.padding(bottom = UiSpacing.CardContent),
)
SuperSpinner(
title = "音频编码器",
summary = "--audio-encoder",
items = audioEncoderEntries,
selectedIndex = audioEncoderIndex,
onSelectedIndexChange = { index ->
onAudioEncoderChange(if (index == 0) "" else audioEncoderDropdownItems[index])
},
enabled = !sessionStarted && audioEnabled,
)
TextField(
value = audioCodecOptions,
onValueChange = onAudioCodecOptionsChange,
label = "--audio-codec-options",
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = UiSpacing.CardContent)
.padding(bottom = UiSpacing.CardContent),
)
}
}
item {
Card {
Text(
text = "--new-display",
modifier = Modifier
.padding(horizontal = UiSpacing.CardTitle)
.padding(
top = UiSpacing.CardContent,
bottom = UiSpacing.FieldLabelBottom,
),
fontWeight = FontWeight.Medium,
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = UiSpacing.CardContent)
.padding(bottom = UiSpacing.CardContent),
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
) {
TextField(
value = newDisplayWidth,
onValueChange = onNewDisplayWidthChange,
label = "width",
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Next,
),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Next) },
),
modifier = Modifier.weight(1f),
)
TextField(
value = newDisplayHeight,
onValueChange = onNewDisplayHeightChange,
label = "height",
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Next,
),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Next) },
),
modifier = Modifier.weight(1f),
)
TextField(
value = newDisplayDpi,
onValueChange = onNewDisplayDpiChange,
label = "dpi",
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = { focusManager.clearFocus() },
),
modifier = Modifier.weight(1f),
)
}
}
}
item {
Card {
Text(
text = "--crop",
modifier = Modifier
.padding(horizontal = UiSpacing.CardTitle)
.padding(
top = UiSpacing.CardContent,
bottom = UiSpacing.FieldLabelBottom,
),
fontWeight = FontWeight.Medium,
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = UiSpacing.CardContent)
.padding(bottom = UiSpacing.CardContent),
verticalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
) {
TextField(
value = cropWidth,
onValueChange = onCropWidthChange,
label = "width",
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Next,
),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Next) },
),
modifier = Modifier.weight(1f),
)
TextField(
value = cropHeight,
onValueChange = onCropHeightChange,
label = "height",
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Next,
),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Next) },
),
modifier = Modifier.weight(1f),
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
) {
TextField(
value = cropX,
onValueChange = onCropXChange,
label = "x",
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Next,
),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Next) },
),
modifier = Modifier.weight(1f),
)
TextField(
value = cropY,
onValueChange = onCropYChange,
label = "y",
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = { focusManager.clearFocus() },
),
modifier = Modifier.weight(1f),
)
}
}
}
}
// TODO: 放进 [AppPageLazyColumn] 里
item { Spacer(Modifier.height(UiSpacing.BottomContent)) }
}
}
private fun presetIndexFromInputForAdvancedPage(raw: String, presets: List<Int>): 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 -> ""
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -8,10 +8,15 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -20,56 +25,78 @@ import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
import io.github.miuzarte.scrcpyforandroid.ScrcpySessionInfo
import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.storage.Settings
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
import io.github.miuzarte.scrcpyforandroid.widgets.FullscreenControlScreen
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonBar
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import top.yukonga.miuix.kmp.basic.Scaffold
data class FullscreenControlLaunch(
val deviceName: String,
val width: Int,
val height: Int,
val codec: String,
)
@Composable
fun FullscreenControlPage(
launch: FullscreenControlLaunch,
fun FullscreenControlScreen(
onBack: () -> Unit,
session: Scrcpy.Session.SessionInfo,
nativeCore: NativeCoreFacade,
virtualButtonsLayout: String,
showDebugInfo: Boolean,
showVirtualButtons: Boolean,
onVideoSizeChanged: (width: Int, height: Int) -> Unit,
onDismiss: () -> Unit,
) {
// Disable predictive back handler temporarily to avoid decoding issues.
BackHandler(enabled = true, onBack = onDismiss)
BackHandler(enabled = true, onBack = onBack)
val context = LocalContext.current
val haptics = rememberAppHaptics()
val scope = rememberCoroutineScope()
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
val activity = remember(context) { context as? Activity }
val virtualButtonLayout = remember(virtualButtonsLayout) {
VirtualButtonActions.splitLayout(VirtualButtonActions.parseStoredLayout(virtualButtonsLayout))
val asBundleShared by appSettings.bundleState.collectAsState()
val asBundleSharedLatest by rememberUpdatedState(asBundleShared)
var asBundle by rememberSaveable(asBundleShared) { mutableStateOf(asBundleShared) }
val asBundleLatest by rememberUpdatedState(asBundle)
LaunchedEffect(asBundleShared) {
if (asBundle != asBundleShared) {
asBundle = asBundleShared
}
val bar = remember(virtualButtonLayout) {
}
LaunchedEffect(asBundle) {
delay(Settings.BUNDLE_SAVE_DELAY)
if (asBundle != asBundleSharedLatest) {
appSettings.saveBundle(asBundle)
}
}
DisposableEffect(Unit) {
onDispose {
taskScope.launch {
appSettings.saveBundle(asBundleLatest)
}
}
}
val buttonItems = remember(asBundle.virtualButtonsLayout) {
VirtualButtonActions.splitLayout(
VirtualButtonActions.parseStoredLayout(asBundle.virtualButtonsLayout)
)
}
val fullscreenDebugInfo = asBundle.fullscreenDebugInfo
val showFullscreenVirtualButtons = asBundle.showFullscreenVirtualButtons
val bar = remember(buttonItems) {
VirtualButtonBar(
outsideActions = virtualButtonLayout.first,
moreActions = virtualButtonLayout.second,
)
}
var session by remember(launch) {
mutableStateOf(
ScrcpySessionInfo(
width = launch.width,
height = launch.height,
deviceName = launch.deviceName.ifBlank { "设备" },
codec = launch.codec.ifBlank { "unknown" },
controlEnabled = true,
),
outsideActions = buttonItems.first,
moreActions = buttonItems.second,
)
}
var currentFps by remember { mutableFloatStateOf(0f) }
DisposableEffect(activity) {
@@ -94,7 +121,6 @@ fun FullscreenControlPage(
DisposableEffect(nativeCore) {
val listener: (Int, Int) -> Unit = { w, h ->
session = session.copy(width = w, height = h)
onVideoSizeChanged(w, h)
}
nativeCore.addVideoSizeListener(listener)
@@ -113,9 +139,15 @@ fun FullscreenControlPage(
}
}
fun sendKeycode(keycode: Int) {
nativeCore.scrcpyInjectKeycode(0, keycode)
nativeCore.scrcpyInjectKeycode(1, keycode)
suspend fun sendKeycode(keycode: Int) {
runCatching {
withContext(Dispatchers.IO) {
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 ->
@@ -127,12 +159,13 @@ fun FullscreenControlPage(
FullscreenControlScreen(
session = session,
nativeCore = nativeCore,
onDismiss = onDismiss,
showDebugInfo = showDebugInfo,
onDismiss = onBack,
showDebugInfo = fullscreenDebugInfo,
currentFps = currentFps,
enableBackHandler = false,
onInjectTouch = { action, pointerId, x, y, pressure, buttons ->
nativeCore.scrcpyInjectTouch(
withContext(Dispatchers.IO) {
nativeCore.session?.injectTouch(
action = action,
pointerId = pointerId,
x = x,
@@ -143,14 +176,17 @@ fun FullscreenControlPage(
actionButton = 0,
buttons = buttons,
)
}
},
)
if (showVirtualButtons) {
if (showFullscreenVirtualButtons) {
bar.Fullscreen(
modifier = Modifier.align(Alignment.BottomCenter),
onAction = { action ->
action.keycode?.let(::sendKeycode)
action.keycode?.let {
sendKeycode(it)
}
},
)
}

View File

@@ -1,876 +0,0 @@
package io.github.miuzarte.scrcpyforandroid.pages
import android.app.Activity
import android.content.Intent
import android.content.pm.ActivityInfo
import android.net.Uri
import android.os.SystemClock
import android.view.WindowManager
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.activity.compose.PredictiveBackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.core.spring
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.rounded.Devices
import androidx.compose.material.icons.rounded.MoreVert
import androidx.compose.material.icons.rounded.Settings
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
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
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberDecoratedNavEntries
import androidx.navigation3.ui.NavDisplay
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults
import io.github.miuzarte.scrcpyforandroid.constants.UiMotion
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.services.MainSettings
import io.github.miuzarte.scrcpyforandroid.services.loadDevicePageSettings
import io.github.miuzarte.scrcpyforandroid.services.loadMainSettings
import io.github.miuzarte.scrcpyforandroid.services.saveMainSettings
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import top.yukonga.miuix.kmp.basic.DropdownImpl
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.ListPopupColumn
import top.yukonga.miuix.kmp.basic.ListPopupDefaults
import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior
import top.yukonga.miuix.kmp.basic.NavigationBar
import top.yukonga.miuix.kmp.basic.NavigationBarItem
import top.yukonga.miuix.kmp.basic.PopupPositionProvider
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.SnackbarHost
import top.yukonga.miuix.kmp.basic.SnackbarHostState
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TopAppBar
import top.yukonga.miuix.kmp.extra.SuperListPopup
import top.yukonga.miuix.kmp.theme.MiuixTheme
import top.yukonga.miuix.kmp.theme.ThemeController
private enum class MainTabDestination(
val title: String,
val label: String,
val icon: ImageVector,
) {
Device(title = "设备", label = "设备", icon = Icons.Rounded.Devices),
Settings(title = "设置", label = "设置", icon = Icons.Rounded.Settings),
}
private sealed interface RootScreen : NavKey {
data object Home : RootScreen
data object Advanced : RootScreen
data object VirtualButtonOrder : RootScreen
data class Fullscreen(val launch: FullscreenControlLaunch) : RootScreen
}
@Composable
fun MainPage() {
val context = LocalContext.current
val activity = remember(context) { context as? Activity }
val initialOrientation = remember(activity) {
activity?.requestedOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
}
val nativeCore = remember(context) { NativeCoreFacade.get(context.applicationContext) }
val initialSettings = remember(context) { loadMainSettings(context) }
val initialDeviceSettings = remember(context) { loadDevicePageSettings(context) }
val snackHostState = remember { SnackbarHostState() }
val tabs = remember { MainTabDestination.entries }
val pagerState = rememberPagerState(
initialPage = MainTabDestination.Device.ordinal,
pageCount = { tabs.size })
val currentTab = tabs[pagerState.currentPage]
val saveableStateHolder = rememberSaveableStateHolder()
val scope = rememberCoroutineScope()
val rootBackStack = remember { mutableStateListOf<NavKey>(RootScreen.Home) }
val currentRootScreen = rootBackStack.lastOrNull() as? RootScreen ?: RootScreen.Home
val deviceScrollBehavior =
MiuixScrollBehavior(canScroll = { currentTab == MainTabDestination.Device })
val settingsScrollBehavior =
MiuixScrollBehavior(canScroll = { currentTab == MainTabDestination.Settings })
val advancedScrollBehavior = MiuixScrollBehavior(
canScroll = {
currentRootScreen is RootScreen.Advanced || currentRootScreen is RootScreen.VirtualButtonOrder
},
)
val stringListSaver = listSaver<List<String>, String>(
save = { value -> ArrayList(value) },
restore = { restored -> restored.toList() },
)
var audioEnabled by rememberSaveable { mutableStateOf(initialSettings.audioEnabled) }
var audioCodec by rememberSaveable { mutableStateOf(initialSettings.audioCodec) }
var videoCodec by rememberSaveable { mutableStateOf(initialSettings.videoCodec) }
var themeBaseIndex by rememberSaveable { mutableIntStateOf(initialSettings.themeBaseIndex) }
var monetEnabled by rememberSaveable { mutableStateOf(initialSettings.monetEnabled) }
var fullscreenDebugInfoEnabled by rememberSaveable { mutableStateOf(initialSettings.fullscreenDebugInfoEnabled) }
var showFullscreenVirtualButtons by rememberSaveable { mutableStateOf(initialSettings.showFullscreenVirtualButtons) }
var showPreviewVirtualButtonText by rememberSaveable { mutableStateOf(initialSettings.showPreviewVirtualButtonText) }
var keepScreenOnWhenStreamingEnabled by rememberSaveable { mutableStateOf(initialSettings.keepScreenOnWhenStreamingEnabled) }
var devicePreviewCardHeightDp by rememberSaveable { mutableIntStateOf(initialSettings.devicePreviewCardHeightDp) }
var virtualButtonsLayout by rememberSaveable { mutableStateOf(initialSettings.virtualButtonsLayout) }
var customServerUri by rememberSaveable { mutableStateOf(initialSettings.customServerUri) }
var serverRemotePath by rememberSaveable { mutableStateOf(initialSettings.serverRemotePath) }
var adbKeyName by rememberSaveable { mutableStateOf(initialSettings.adbKeyName) }
var adbPairingAutoDiscoverOnDialogOpen by rememberSaveable {
mutableStateOf(initialSettings.adbPairingAutoDiscoverOnDialogOpen)
}
var adbAutoReconnectPairedDevice by rememberSaveable {
mutableStateOf(initialSettings.adbAutoReconnectPairedDevice)
}
var adbMdnsLanDiscoveryEnabled by rememberSaveable {
mutableStateOf(initialSettings.adbMdnsLanDiscoveryEnabled)
}
var noControl by rememberSaveable { mutableStateOf(initialDeviceSettings.noControl) }
var videoEncoder by rememberSaveable { mutableStateOf(initialDeviceSettings.videoEncoder) }
var videoCodecOptions by rememberSaveable { mutableStateOf(initialDeviceSettings.videoCodecOptions) }
var audioEncoder by rememberSaveable { mutableStateOf(initialDeviceSettings.audioEncoder) }
var audioCodecOptions by rememberSaveable { mutableStateOf(initialDeviceSettings.audioCodecOptions) }
var audioDup by rememberSaveable { mutableStateOf(initialDeviceSettings.audioDup) }
var audioSourcePreset by rememberSaveable { mutableStateOf(initialDeviceSettings.audioSourcePreset) }
var audioSourceCustom by rememberSaveable { mutableStateOf(initialDeviceSettings.audioSourceCustom) }
var videoSourcePreset by rememberSaveable { mutableStateOf(initialDeviceSettings.videoSourcePreset) }
var cameraIdInput by rememberSaveable { mutableStateOf(initialDeviceSettings.cameraIdInput) }
var cameraFacingPreset by rememberSaveable { mutableStateOf(initialDeviceSettings.cameraFacingPreset) }
var cameraSizePreset by rememberSaveable { mutableStateOf(initialDeviceSettings.cameraSizePreset) }
var cameraSizeCustom by rememberSaveable { mutableStateOf(initialDeviceSettings.cameraSizeCustom) }
var cameraArInput by rememberSaveable { mutableStateOf(initialDeviceSettings.cameraAr) }
var cameraFpsInput by rememberSaveable { mutableStateOf(initialDeviceSettings.cameraFps) }
var cameraHighSpeed by rememberSaveable { mutableStateOf(initialDeviceSettings.cameraHighSpeed) }
var noAudioPlayback by rememberSaveable { mutableStateOf(initialDeviceSettings.noAudioPlayback) }
var noVideo by rememberSaveable { mutableStateOf(initialDeviceSettings.noVideo) }
var requireAudio by rememberSaveable { mutableStateOf(initialDeviceSettings.requireAudio) }
var turnScreenOff by rememberSaveable { mutableStateOf(initialDeviceSettings.turnScreenOff) }
var maxSizeInput by rememberSaveable { mutableStateOf(initialDeviceSettings.maxSizeInput) }
var maxFpsInput by rememberSaveable { mutableStateOf(initialDeviceSettings.maxFpsInput) }
var newDisplayWidth by rememberSaveable { mutableStateOf(initialDeviceSettings.newDisplayWidth) }
var newDisplayHeight by rememberSaveable { mutableStateOf(initialDeviceSettings.newDisplayHeight) }
var newDisplayDpi by rememberSaveable { mutableStateOf(initialDeviceSettings.newDisplayDpi) }
var displayIdInput by rememberSaveable { mutableStateOf(initialDeviceSettings.displayIdInput) }
var cropWidth by rememberSaveable { mutableStateOf(initialDeviceSettings.cropWidth) }
var cropHeight by rememberSaveable { mutableStateOf(initialDeviceSettings.cropHeight) }
var cropX by rememberSaveable { mutableStateOf(initialDeviceSettings.cropX) }
var cropY by rememberSaveable { mutableStateOf(initialDeviceSettings.cropY) }
val videoEncoderOptions = remember { mutableStateListOf<String>() }
val audioEncoderOptions = remember { mutableStateListOf<String>() }
val videoEncoderTypeMap = remember { mutableStateMapOf<String, String>() }
val audioEncoderTypeMap = remember { mutableStateMapOf<String, String>() }
val cameraSizeOptions = remember { mutableStateListOf<String>() }
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 canClearLogs by remember { mutableStateOf(false) }
var showDeviceMenu by rememberSaveable { mutableStateOf(false) }
var lastExitBackPressAtMs by rememberSaveable { mutableLongStateOf(0L) }
var fullscreenOrientation by rememberSaveable {
mutableIntStateOf(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT)
}
val themeMode = resolveThemeMode(themeBaseIndex, monetEnabled)
val themeController = remember(themeMode) { ThemeController(colorSchemeMode = themeMode) }
// Restore system orientation when MainPage leaves composition.
DisposableEffect(activity) {
onDispose {
activity?.requestedOrientation = initialOrientation
}
}
// Keep-screen-on is controlled globally, so fullscreen and preview share the same behavior.
DisposableEffect(activity, keepScreenOnWhenStreamingEnabled, sessionStarted) {
val window = activity?.window
val shouldKeepScreenOn = keepScreenOnWhenStreamingEnabled && sessionStarted
if (window != null && shouldKeepScreenOn) {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
onDispose {
if (window != null && shouldKeepScreenOn) {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
}
// Fullscreen route can force orientation based on stream ratio; all other routes are portrait.
LaunchedEffect(activity, currentRootScreen, fullscreenOrientation) {
val targetOrientation = when (currentRootScreen) {
is RootScreen.Fullscreen -> fullscreenOrientation
else -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
activity?.requestedOrientation = targetOrientation
}
LaunchedEffect(
audioEnabled,
audioCodec,
videoCodec,
themeBaseIndex,
monetEnabled,
fullscreenDebugInfoEnabled,
showFullscreenVirtualButtons,
showPreviewVirtualButtonText,
keepScreenOnWhenStreamingEnabled,
devicePreviewCardHeightDp,
virtualButtonsLayout,
customServerUri,
serverRemotePath,
adbKeyName,
adbPairingAutoDiscoverOnDialogOpen,
adbAutoReconnectPairedDevice,
adbMdnsLanDiscoveryEnabled,
) {
saveMainSettings(
context,
MainSettings(
audioEnabled = audioEnabled,
audioCodec = audioCodec,
videoCodec = videoCodec,
themeBaseIndex = themeBaseIndex,
monetEnabled = monetEnabled,
fullscreenDebugInfoEnabled = fullscreenDebugInfoEnabled,
showFullscreenVirtualButtons = showFullscreenVirtualButtons,
showPreviewVirtualButtonText = showPreviewVirtualButtonText,
keepScreenOnWhenStreamingEnabled = keepScreenOnWhenStreamingEnabled,
devicePreviewCardHeightDp = devicePreviewCardHeightDp,
virtualButtonsLayout = virtualButtonsLayout,
customServerUri = customServerUri,
serverRemotePath = serverRemotePath,
adbKeyName = adbKeyName,
adbPairingAutoDiscoverOnDialogOpen = adbPairingAutoDiscoverOnDialogOpen,
adbAutoReconnectPairedDevice = adbAutoReconnectPairedDevice,
adbMdnsLanDiscoveryEnabled = adbMdnsLanDiscoveryEnabled,
),
)
}
LaunchedEffect(adbKeyName) {
nativeCore.setAdbKeyName(adbKeyName.ifBlank { AppDefaults.ADB_KEY_NAME })
}
fun popRoot() {
if (rootBackStack.size > 1) {
rootBackStack.removeAt(rootBackStack.lastIndex)
}
}
// Unified back behavior:
// 1) pop inner route
// 2) switch tab back to Device
// 3) double-back to exit and disconnect adb/scrcpy
fun handleBackNavigation() {
if (rootBackStack.size > 1) {
popRoot()
} else if (pagerState.currentPage != MainTabDestination.Device.ordinal) {
scope.launch {
pagerState.animateScrollToPage(
page = MainTabDestination.Device.ordinal,
animationSpec = spring(
dampingRatio = UiMotion.PAGE_SWITCH_DAMPING_RATIO,
stiffness = UiMotion.PAGE_SWITCH_STIFFNESS,
),
)
}
} else {
val now = SystemClock.elapsedRealtime()
if (now - lastExitBackPressAtMs > 2_000L) {
lastExitBackPressAtMs = now
Toast.makeText(context, "再按一次返回键退出", Toast.LENGTH_SHORT).show()
return
}
lastExitBackPressAtMs = 0L
scope.launch {
withContext(Dispatchers.IO) {
runCatching { nativeCore.scrcpyStop() }
runCatching { nativeCore.adbDisconnect() }
}
activity?.finish()
}
}
}
val canNavigateBack = rootBackStack.size > 1 ||
pagerState.currentPage != MainTabDestination.Device.ordinal
BackHandler(enabled = currentRootScreen !is RootScreen.Fullscreen) {
handleBackNavigation()
}
PredictiveBackHandler(
enabled = canNavigateBack && currentRootScreen !is RootScreen.Fullscreen
) { progress ->
try {
progress.collect { }
handleBackNavigation()
} catch (_: CancellationException) {
// Gesture was cancelled by the system/user.
}
}
val picker =
rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? ->
if (uri == null) return@rememberLauncherForActivityResult
runCatching {
context.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
}
customServerUri = uri.toString()
}
val rootEntryProvider = entryProvider<NavKey> {
entry(RootScreen.Home) {
Scaffold(
bottomBar = {
NavigationBar {
tabs.forEach { tab ->
NavigationBarItem(
selected = currentTab == tab,
onClick = {
scope.launch {
pagerState.animateScrollToPage(
page = tab.ordinal,
animationSpec = spring(
dampingRatio = UiMotion.PAGE_SWITCH_DAMPING_RATIO,
stiffness = UiMotion.PAGE_SWITCH_STIFFNESS,
),
)
}
},
icon = tab.icon,
label = tab.label,
)
}
}
},
snackbarHost = { SnackbarHost(snackHostState) },
) { contentPadding ->
HorizontalPager(
modifier = Modifier
.fillMaxSize()
.padding(bottom = contentPadding.calculateBottomPadding()),
state = pagerState,
beyondViewportPageCount = 1,
) { page ->
val tab = tabs[page]
saveableStateHolder.SaveableStateProvider(tab.name) {
when (tab) {
MainTabDestination.Device -> Scaffold(
topBar = {
TopAppBar(
title = tab.title,
actions = {
IconButton(
onClick = { showDeviceMenu = true },
holdDownState = showDeviceMenu,
) {
Icon(
Icons.Rounded.MoreVert,
contentDescription = "更多"
)
}
DeviceMenuPopup(
show = showDeviceMenu,
canClearLogs = canClearLogs,
onDismissRequest = { showDeviceMenu = false },
onReorderDevices = {
openReorderDevicesAction?.invoke()
showDeviceMenu = false
},
onOpenVirtualButtonOrder = {
rootBackStack.add(RootScreen.VirtualButtonOrder)
showDeviceMenu = false
},
onClearLogs = {
clearLogsAction?.invoke()
showDeviceMenu = false
},
)
},
scrollBehavior = deviceScrollBehavior,
)
},
) { pagePadding ->
DeviceTabScreen(
contentPadding = pagePadding,
nativeCore = nativeCore,
snack = snackHostState,
scrollBehavior = deviceScrollBehavior,
virtualButtonsLayout = virtualButtonsLayout,
showPreviewVirtualButtonText = showPreviewVirtualButtonText,
previewCardHeightDp = devicePreviewCardHeightDp,
themeBaseIndex = themeBaseIndex,
customServerUri = customServerUri,
serverRemotePath = serverRemotePath,
onServerRemotePathChange = { serverRemotePath = it },
videoCodec = videoCodec,
onVideoCodecChange = { videoCodec = it },
audioEnabled = audioEnabled,
onAudioEnabledChange = { audioEnabled = it },
audioCodec = audioCodec,
onAudioCodecChange = { audioCodec = it },
noControl = noControl,
onNoControlChange = {
noControl = it
if (it) {
turnScreenOff = false
}
},
videoEncoder = videoEncoder,
onVideoEncoderChange = { videoEncoder = it },
videoCodecOptions = videoCodecOptions,
onVideoCodecOptionsChange = { videoCodecOptions = it },
audioEncoder = audioEncoder,
onAudioEncoderChange = { audioEncoder = it },
audioCodecOptions = audioCodecOptions,
onAudioCodecOptionsChange = { audioCodecOptions = it },
audioDup = audioDup,
onAudioDupChange = { audioDup = it },
audioSourcePreset = audioSourcePreset,
onAudioSourcePresetChange = { audioSourcePreset = it },
audioSourceCustom = audioSourceCustom,
onAudioSourceCustomChange = { audioSourceCustom = it },
videoSourcePreset = videoSourcePreset,
onVideoSourcePresetChange = { videoSourcePreset = it },
cameraIdInput = cameraIdInput,
onCameraIdInputChange = { cameraIdInput = it },
cameraFacingPreset = cameraFacingPreset,
onCameraFacingPresetChange = { cameraFacingPreset = it },
cameraSizePreset = cameraSizePreset,
onCameraSizePresetChange = { cameraSizePreset = it },
cameraSizeCustom = cameraSizeCustom,
onCameraSizeCustomChange = { cameraSizeCustom = it },
cameraArInput = cameraArInput,
onCameraArInputChange = { cameraArInput = it },
cameraFpsInput = cameraFpsInput,
onCameraFpsInputChange = { cameraFpsInput = it },
cameraHighSpeed = cameraHighSpeed,
onCameraHighSpeedChange = { cameraHighSpeed = it },
noAudioPlayback = noAudioPlayback,
onNoAudioPlaybackChange = { noAudioPlayback = it },
noVideo = noVideo,
requireAudio = requireAudio,
onRequireAudioChange = { requireAudio = it },
turnScreenOff = turnScreenOff,
onTurnScreenOffChange = { turnScreenOff = it },
maxSizeInput = maxSizeInput,
onMaxSizeInputChange = { maxSizeInput = it },
maxFpsInput = maxFpsInput,
onMaxFpsInputChange = { maxFpsInput = it },
newDisplayWidth = newDisplayWidth,
onNewDisplayWidthChange = { newDisplayWidth = it },
newDisplayHeight = newDisplayHeight,
onNewDisplayHeightChange = { newDisplayHeight = it },
newDisplayDpi = newDisplayDpi,
onNewDisplayDpiChange = { newDisplayDpi = it },
displayIdInput = displayIdInput,
onDisplayIdInputChange = { displayIdInput = it },
cropWidth = cropWidth,
onCropWidthChange = { cropWidth = it },
cropHeight = cropHeight,
onCropHeightChange = { cropHeight = it },
cropX = cropX,
onCropXChange = { cropX = it },
cropY = cropY,
onCropYChange = { cropY = it },
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 = {
openReorderDevicesAction = it
},
onOpenAdvancedPage = { rootBackStack.add(RootScreen.Advanced) },
onOpenFullscreenPage = { session ->
fullscreenOrientation =
if (session.width >= session.height) {
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
} else {
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
rootBackStack.add(
RootScreen.Fullscreen(
launch = FullscreenControlLaunch(
deviceName = session.deviceName,
width = session.width,
height = session.height,
codec = session.codec,
),
),
)
},
adbPairingAutoDiscoverOnDialogOpen = adbPairingAutoDiscoverOnDialogOpen,
adbAutoReconnectPairedDevice = adbAutoReconnectPairedDevice,
adbMdnsLanDiscoveryEnabled = adbMdnsLanDiscoveryEnabled,
)
}
MainTabDestination.Settings -> Scaffold(
topBar = {
TopAppBar(
title = tab.title,
scrollBehavior = settingsScrollBehavior,
)
},
) { pagePadding ->
SettingsScreen(
contentPadding = pagePadding,
themeBaseIndex = themeBaseIndex,
onThemeBaseIndexChange = { themeBaseIndex = it },
monetEnabled = monetEnabled,
onMonetEnabledChange = { monetEnabled = it },
fullscreenDebugInfoEnabled = fullscreenDebugInfoEnabled,
onFullscreenDebugInfoEnabledChange = {
fullscreenDebugInfoEnabled = it
},
keepScreenOnWhenStreamingEnabled = keepScreenOnWhenStreamingEnabled,
onKeepScreenOnWhenStreamingEnabledChange = {
keepScreenOnWhenStreamingEnabled = it
},
devicePreviewCardHeightDp = devicePreviewCardHeightDp,
onDevicePreviewCardHeightDpChange = {
devicePreviewCardHeightDp = it.coerceAtLeast(120)
},
onOpenReorderDevices = {
openReorderDevicesAction?.invoke()
},
onOpenVirtualButtonOrder = {
rootBackStack.add(RootScreen.VirtualButtonOrder)
},
showFullscreenVirtualButtons = showFullscreenVirtualButtons,
onShowFullscreenVirtualButtonsChange = {
showFullscreenVirtualButtons = it
},
customServerUri = customServerUri,
onPickServer = {
picker.launch(
arrayOf(
"application/java-archive",
"application/octet-stream",
"*/*"
)
)
},
onClearServer = { customServerUri = null },
serverRemotePath = serverRemotePath,
onServerRemotePathChange = { serverRemotePath = it },
adbKeyName = adbKeyName,
onAdbKeyNameChange = { adbKeyName = it },
adbPairingAutoDiscoverOnDialogOpen = adbPairingAutoDiscoverOnDialogOpen,
onAdbPairingAutoDiscoverOnDialogOpenChange = {
adbPairingAutoDiscoverOnDialogOpen = it
},
adbAutoReconnectPairedDevice = adbAutoReconnectPairedDevice,
onAdbAutoReconnectPairedDeviceChange = {
adbAutoReconnectPairedDevice = it
},
scrollBehavior = settingsScrollBehavior,
)
}
}
}
}
}
}
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(
title = "高级参数",
navigationIcon = {
IconButton(onClick = { popRoot() }) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "返回"
)
}
},
scrollBehavior = advancedScrollBehavior,
)
},
snackbarHost = { SnackbarHost(snackHostState) },
) { pagePadding ->
AdvancedConfigPage(
contentPadding = pagePadding,
scrollBehavior = advancedScrollBehavior,
sessionStarted = sessionStarted,
snackbarHostState = snackHostState,
audioEnabled = audioEnabled,
noControl = noControl,
onNoControlChange = {
noControl = it
if (it) {
turnScreenOff = false
}
},
audioDup = audioDup,
onAudioDupChange = { audioDup = it },
audioSourcePreset = audioSourcePreset,
onAudioSourcePresetChange = { audioSourcePreset = it },
audioSourceCustom = audioSourceCustom,
onAudioSourceCustomChange = { audioSourceCustom = it },
videoSourcePreset = videoSourcePreset,
onVideoSourcePresetChange = { videoSourcePreset = it },
cameraIdInput = cameraIdInput,
onCameraIdInputChange = { cameraIdInput = it },
cameraFacingPreset = cameraFacingPreset,
onCameraFacingPresetChange = { cameraFacingPreset = it },
cameraSizePreset = cameraSizePreset,
onCameraSizePresetChange = { cameraSizePreset = it },
cameraSizeCustom = cameraSizeCustom,
onCameraSizeCustomChange = { cameraSizeCustom = it },
cameraSizeDropdownItems = listOf("默认") + cameraSizeOptions + listOf("自定义"),
cameraSizeIndex = when (cameraSizePreset) {
"custom" -> cameraSizeOptions.size + 1
in cameraSizeOptions -> cameraSizeOptions.indexOf(cameraSizePreset) + 1
else -> 0
},
cameraArInput = cameraArInput,
onCameraArInputChange = { cameraArInput = it },
cameraFpsInput = cameraFpsInput,
onCameraFpsInputChange = { cameraFpsInput = it },
cameraHighSpeed = cameraHighSpeed,
onCameraHighSpeedChange = { cameraHighSpeed = it },
noAudioPlayback = noAudioPlayback,
onNoAudioPlaybackChange = { noAudioPlayback = it },
noVideo = noVideo,
onNoVideoChange = { noVideo = it },
requireAudio = requireAudio,
onRequireAudioChange = { requireAudio = it },
turnScreenOff = turnScreenOff,
onTurnScreenOffChange = { turnScreenOff = it },
maxSizeInput = maxSizeInput,
onMaxSizeInputChange = { maxSizeInput = it },
maxFpsInput = maxFpsInput,
onMaxFpsInputChange = { maxFpsInput = it },
videoEncoderDropdownItems = videoEncoderDropdownItems,
videoEncoderTypeMap = videoEncoderTypeMap,
videoEncoderIndex = videoEncoderIndex,
onVideoEncoderChange = { videoEncoder = it },
videoCodecOptions = videoCodecOptions,
onVideoCodecOptionsChange = { videoCodecOptions = it },
audioEncoderDropdownItems = audioEncoderDropdownItems,
audioEncoderTypeMap = audioEncoderTypeMap,
audioEncoderIndex = audioEncoderIndex,
onAudioEncoderChange = { audioEncoder = it },
audioCodecOptions = audioCodecOptions,
onAudioCodecOptionsChange = { audioCodecOptions = it },
onRefreshEncoders = { refreshEncodersAction?.invoke() },
onRefreshCameraSizes = { refreshCameraSizesAction?.invoke() },
newDisplayWidth = newDisplayWidth,
onNewDisplayWidthChange = { newDisplayWidth = it },
newDisplayHeight = newDisplayHeight,
onNewDisplayHeightChange = { newDisplayHeight = it },
newDisplayDpi = newDisplayDpi,
onNewDisplayDpiChange = { newDisplayDpi = it },
displayIdInput = displayIdInput,
onDisplayIdInputChange = { displayIdInput = it },
cropWidth = cropWidth,
onCropWidthChange = { cropWidth = it },
cropHeight = cropHeight,
onCropHeightChange = { cropHeight = it },
cropX = cropX,
onCropXChange = { cropX = it },
cropY = cropY,
onCropYChange = { cropY = it },
)
}
}
entry(RootScreen.VirtualButtonOrder) {
Scaffold(
modifier = Modifier.nestedScroll(advancedScrollBehavior.nestedScrollConnection),
topBar = {
TopAppBar(
title = "虚拟按钮排序",
navigationIcon = {
IconButton(onClick = { popRoot() }) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "返回"
)
}
},
scrollBehavior = advancedScrollBehavior,
)
},
) { pagePadding ->
VirtualButtonOrderPage(
contentPadding = pagePadding,
scrollBehavior = advancedScrollBehavior,
layoutString = virtualButtonsLayout,
onLayoutChange = { layout ->
virtualButtonsLayout = layout
},
showPreviewText = showPreviewVirtualButtonText,
onShowPreviewTextChange = { showPreviewVirtualButtonText = it },
)
}
}
entry<RootScreen.Fullscreen> { screen ->
FullscreenControlPage(
launch = screen.launch,
nativeCore = nativeCore,
virtualButtonsLayout = virtualButtonsLayout,
showDebugInfo = fullscreenDebugInfoEnabled,
showVirtualButtons = showFullscreenVirtualButtons,
onVideoSizeChanged = { width, height ->
fullscreenOrientation = if (width >= height) {
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
} else {
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
},
onDismiss = { popRoot() },
)
}
}
val rootEntries = rememberDecoratedNavEntries(
backStack = rootBackStack,
entryProvider = rootEntryProvider,
)
MiuixTheme(controller = themeController) {
NavDisplay(
entries = rootEntries,
onBack = { popRoot() },
)
}
}
@Composable
private fun DeviceMenuPopup(
show: Boolean,
canClearLogs: Boolean,
onDismissRequest: () -> Unit,
onReorderDevices: () -> Unit,
onOpenVirtualButtonOrder: () -> Unit,
onClearLogs: () -> Unit,
) {
SuperListPopup(
show = show,
popupPositionProvider = ListPopupDefaults.ContextMenuPositionProvider,
alignment = PopupPositionProvider.Align.TopEnd,
onDismissRequest = onDismissRequest,
enableWindowDim = false,
) {
ListPopupColumn {
DeviceMenuPopupItem(
text = "快速设备排序",
optionSize = 3,
index = 0,
onClick = onReorderDevices,
)
DeviceMenuPopupItem(
text = "虚拟按钮排序",
optionSize = 3,
index = 1,
onClick = onOpenVirtualButtonOrder,
)
DeviceMenuPopupItem(
text = "清空日志",
optionSize = 3,
index = 2,
enabled = canClearLogs,
onClick = onClearLogs,
)
}
}
}
@Composable
private fun DeviceMenuPopupItem(
text: String,
optionSize: Int,
index: Int,
enabled: Boolean = true,
// TODO: (Int) -> Unit
onClick: () -> Unit,
) {
if (enabled) {
DropdownImpl(
text = text,
optionSize = optionSize,
isSelected = false,
index = index,
onSelectedIndexChange = { onClick() },
)
return
}
val additionalTopPadding = if (index == 0) UiSpacing.PopupHorizontal else UiSpacing.PageItem
val additionalBottomPadding =
if (index == optionSize - 1) UiSpacing.PopupHorizontal else UiSpacing.PageItem
Text(
text = text,
fontSize = MiuixTheme.textStyles.body1.fontSize,
fontWeight = FontWeight.Medium,
color = MiuixTheme.colorScheme.disabledOnSecondaryVariant,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = UiSpacing.PopupHorizontal)
.padding(top = additionalTopPadding, bottom = additionalBottomPadding),
)
}

View File

@@ -0,0 +1,431 @@
package io.github.miuzarte.scrcpyforandroid.pages
import android.app.Activity
import android.content.Intent
import android.content.pm.ActivityInfo
import android.net.Uri
import android.os.SystemClock
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.activity.compose.PredictiveBackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.core.spring
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Devices
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberDecoratedNavEntries
import androidx.navigation3.ui.NavDisplay
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
import io.github.miuzarte.scrcpyforandroid.constants.ThemeModes
import io.github.miuzarte.scrcpyforandroid.constants.UiMotion
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.services.SnackbarController
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
import io.github.miuzarte.scrcpyforandroid.storage.Settings
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
import io.github.miuzarte.scrcpyforandroid.storage.Storage.quickDevices
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior
import top.yukonga.miuix.kmp.basic.NavigationBar
import top.yukonga.miuix.kmp.basic.NavigationBarItem
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.SnackbarHost
import top.yukonga.miuix.kmp.basic.SnackbarHostState
import top.yukonga.miuix.kmp.theme.ColorSchemeMode
import top.yukonga.miuix.kmp.theme.MiuixTheme
import top.yukonga.miuix.kmp.theme.ThemeController
private enum class MainBottomTabDestination(
val label: String,
val icon: ImageVector,
) {
Device(label = "设备", icon = Icons.Rounded.Devices),
Settings(label = "设置", icon = Icons.Rounded.Settings);
}
sealed interface RootScreen : NavKey {
data object Home : RootScreen
data object Advanced : RootScreen
data object VirtualButtonOrder : RootScreen
data class Fullscreen(val session: Scrcpy.Session.SessionInfo) : RootScreen
}
@Composable
fun MainScreen() {
val context = LocalContext.current
val appContext = context.applicationContext
val scope = rememberCoroutineScope()
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
val activity = remember(context) { context as? Activity }
val initialOrientation = remember(activity) {
activity?.requestedOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
}
DisposableEffect(activity) {
onDispose {
activity?.requestedOrientation = initialOrientation
}
}
val nativeCore = remember(appContext) {
NativeCoreFacade.get(appContext)
}
val adbService = remember(appContext) {
NativeAdbService(appContext)
}
val snackHostState = remember { SnackbarHostState() }
val snackbarController = remember(scope, snackHostState) {
SnackbarController(
scope = scope,
hostState = snackHostState,
)
}
val saveableStateHolder = rememberSaveableStateHolder()
val tabs = remember { MainBottomTabDestination.entries }
val pagerState = rememberPagerState(
initialPage = MainBottomTabDestination.Device.ordinal,
pageCount = { tabs.size })
val currentTab = tabs[pagerState.currentPage]
val rootBackStack = remember { mutableStateListOf<NavKey>(RootScreen.Home) }
val currentRootScreen = rootBackStack.lastOrNull() as? RootScreen ?: RootScreen.Home
val devicesPageScrollBehavior = MiuixScrollBehavior(
canScroll = { currentTab == MainBottomTabDestination.Device })
val settingsPageScrollBehavior = MiuixScrollBehavior(
canScroll = { currentTab == MainBottomTabDestination.Settings })
val advancedPageScrollBehavior = MiuixScrollBehavior(
canScroll = {
when (currentRootScreen) {
is RootScreen.Advanced -> true
is RootScreen.VirtualButtonOrder -> true
else -> false
}
})
fun popRoot() {
if (rootBackStack.size > 1)
rootBackStack.removeAt(rootBackStack.lastIndex)
}
// Unified back behavior:
// 1) pop inner route
// 2) switch tab back to Device
// 3) double-back to exit and disconnect adb/scrcpy
var lastExitBackPressAtMs by rememberSaveable { mutableLongStateOf(0L) }
val asBundleShared by appSettings.bundleState.collectAsState()
val asBundleSharedLatest by rememberUpdatedState(asBundleShared)
var asBundle by rememberSaveable(asBundleShared) { mutableStateOf(asBundleShared) }
val asBundleLatest by rememberUpdatedState(asBundle)
LaunchedEffect(asBundleShared) {
if (asBundle != asBundleShared) {
asBundle = asBundleShared
}
}
LaunchedEffect(asBundle) {
delay(Settings.BUNDLE_SAVE_DELAY)
if (asBundle != asBundleSharedLatest) {
appSettings.saveBundle(asBundle)
}
}
DisposableEffect(Unit) {
onDispose {
taskScope.launch {
appSettings.saveBundle(asBundleLatest)
}
}
}
val qdBundleShared by quickDevices.bundleState.collectAsState()
val qdBundleSharedLatest by rememberUpdatedState(qdBundleShared)
var qdBundle by rememberSaveable(qdBundleShared) { mutableStateOf(qdBundleShared) }
val qdBundleLatest by rememberUpdatedState(qdBundle)
LaunchedEffect(qdBundleShared) {
if (qdBundle != qdBundleShared) {
qdBundle = qdBundleShared
}
}
LaunchedEffect(qdBundle) {
delay(Settings.BUNDLE_SAVE_DELAY)
if (qdBundle != qdBundleSharedLatest) {
quickDevices.saveBundle(qdBundle)
}
}
DisposableEffect(Unit) {
onDispose {
taskScope.launch {
quickDevices.saveBundle(qdBundleLatest)
}
}
}
val customServerUri = asBundle.customServerUri
.ifBlank { null }
val customServerVersion = asBundle.customServerVersion
.ifBlank { Scrcpy.DEFAULT_SERVER_VERSION }
val serverRemotePath = asBundle.serverRemotePath
.ifBlank { AppSettings.SERVER_REMOTE_PATH.defaultValue }
val scrcpy = remember(
appContext,
adbService,
customServerUri,
customServerVersion,
serverRemotePath,
) {
Scrcpy(
appContext = appContext,
adbService = adbService,
customServerUri = customServerUri,
serverVersion = customServerVersion,
serverRemotePath = serverRemotePath,
)
}
fun handleBackNavigation() {
if (rootBackStack.size > 1) {
popRoot()
} else if (pagerState.currentPage != MainBottomTabDestination.Device.ordinal) {
scope.launch {
pagerState.animateScrollToPage(
page = MainBottomTabDestination.Device.ordinal,
animationSpec = spring(
dampingRatio = UiMotion.PAGE_SWITCH_DAMPING_RATIO,
stiffness = UiMotion.PAGE_SWITCH_STIFFNESS,
),
)
}
} else {
val now = SystemClock.elapsedRealtime()
if (now - lastExitBackPressAtMs > 2_000L) {
lastExitBackPressAtMs = now
Toast.makeText(context, "再按一次返回键退出", Toast.LENGTH_SHORT).show()
return
}
lastExitBackPressAtMs = 0L
scope.launch {
withContext(Dispatchers.IO) {
runCatching { scrcpy.stop() }
runCatching { adbService.disconnect() }
}
activity?.finish()
}
}
}
val canNavigateBack = rootBackStack.size > 1
|| pagerState.currentPage != MainBottomTabDestination.Device.ordinal
BackHandler(enabled = currentRootScreen !is RootScreen.Fullscreen) {
handleBackNavigation()
}
PredictiveBackHandler(
enabled = canNavigateBack && currentRootScreen !is RootScreen.Fullscreen
) { progress ->
try {
progress.collect { }
handleBackNavigation()
} catch (_: CancellationException) {
// Gesture was cancelled by the system/user.
}
}
var showReorderDevices by rememberSaveable { mutableStateOf(false) }
var fullscreenOrientation by rememberSaveable {
mutableIntStateOf(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT)
}
// Fullscreen route can force orientation based on stream ratio; all other routes are portrait.
LaunchedEffect(activity, currentRootScreen, fullscreenOrientation) {
val targetOrientation = when (currentRootScreen) {
is RootScreen.Fullscreen -> fullscreenOrientation
else -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
activity?.requestedOrientation = targetOrientation
}
LaunchedEffect(asBundle.adbKeyName) {
adbService.keyName = asBundle.adbKeyName.ifBlank { AppSettings.ADB_KEY_NAME.defaultValue }
}
val picker = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri: Uri? ->
if (uri == null) return@rememberLauncherForActivityResult
runCatching {
context.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
}
asBundle = asBundle.copy(customServerUri = uri.toString())
}
val rootEntryProvider = entryProvider<NavKey> {
entry(RootScreen.Home) {
Scaffold(
bottomBar = {
NavigationBar {
tabs.forEach { tab ->
NavigationBarItem(
selected = currentTab == tab,
onClick = {
scope.launch {
pagerState.animateScrollToPage(
page = tab.ordinal,
animationSpec = spring(
dampingRatio = UiMotion.PAGE_SWITCH_DAMPING_RATIO,
stiffness = UiMotion.PAGE_SWITCH_STIFFNESS,
),
)
}
},
icon = tab.icon,
label = tab.label,
)
}
}
},
snackbarHost = { SnackbarHost(snackHostState) },
) { contentPadding ->
HorizontalPager(
modifier = Modifier
.fillMaxSize()
.padding(bottom = contentPadding.calculateBottomPadding()),
state = pagerState,
beyondViewportPageCount = 1,
) { page ->
val tab = tabs[page]
saveableStateHolder.SaveableStateProvider(tab.name) {
when (tab) {
MainBottomTabDestination.Device -> DeviceTabScreen(
nativeCore = nativeCore,
adbService = adbService,
scrcpy = scrcpy,
snackbar = snackbarController,
scrollBehavior = devicesPageScrollBehavior,
onOpenVirtualButtonOrder = { rootBackStack.add(RootScreen.VirtualButtonOrder) },
onOpenReorderDevices = { showReorderDevices = true },
onOpenAdvancedPage = { rootBackStack.add(RootScreen.Advanced) },
onOpenFullscreenPage = { session ->
fullscreenOrientation =
if (session.width >= session.height) {
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
} else {
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
rootBackStack.add(
RootScreen.Fullscreen(session),
)
},
)
MainBottomTabDestination.Settings -> SettingsScreen(
scrollBehavior = settingsPageScrollBehavior,
snackbar = snackbarController,
onOpenReorderDevices = { showReorderDevices = true },
onOpenVirtualButtonOrder = { rootBackStack.add(RootScreen.VirtualButtonOrder) },
onPickServer = {
picker.launch(
arrayOf(
"application/java-archive",
"application/octet-stream",
"*/*"
)
)
},
)
}
}
}
ReorderDevicesScreen(
show = showReorderDevices,
onDismissRequest = { showReorderDevices = false },
)
}
}
entry(RootScreen.Advanced) {
ScrcpyAllOptionsScreen(
onBack = ::popRoot,
scrollBehavior = advancedPageScrollBehavior,
snackbar = snackbarController,
scrcpy = scrcpy,
)
}
entry(RootScreen.VirtualButtonOrder) {
VirtualButtonOrderScreen(
onBack = ::popRoot,
scrollBehavior = advancedPageScrollBehavior,
)
}
entry<RootScreen.Fullscreen> { screen ->
FullscreenControlScreen(
onBack = ::popRoot,
session = screen.session,
nativeCore = nativeCore,
onVideoSizeChanged = { width, height ->
fullscreenOrientation =
if (width >= height) ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
else ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
},
)
}
}
val rootEntries = rememberDecoratedNavEntries(
backStack = rootBackStack,
entryProvider = rootEntryProvider,
)
val themeMode = when (asBundle.themeBaseIndex.coerceIn(0, ThemeModes.baseOptions.lastIndex)) {
1 -> if (!asBundle.monet) ColorSchemeMode.Light else ColorSchemeMode.MonetLight
2 -> if (!asBundle.monet) ColorSchemeMode.Dark else ColorSchemeMode.MonetDark
else -> if (!asBundle.monet) ColorSchemeMode.System else ColorSchemeMode.MonetSystem
}
val themeController = remember(themeMode) { ThemeController(colorSchemeMode = themeMode) }
MiuixTheme(controller = themeController) {
NavDisplay(
entries = rootEntries,
onBack = ::popRoot,
)
}
}

View File

@@ -0,0 +1,94 @@
package io.github.miuzarte.scrcpyforandroid.pages
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts
import io.github.miuzarte.scrcpyforandroid.storage.Settings
import io.github.miuzarte.scrcpyforandroid.storage.Storage.quickDevices
import io.github.miuzarte.scrcpyforandroid.widgets.ReorderableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import top.yukonga.miuix.kmp.extra.SuperBottomSheet
@Composable
fun ReorderDevicesScreen(
show: Boolean,
onDismissRequest: () -> Unit,
) {
val scope = rememberCoroutineScope()
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
val qdBundleShared by quickDevices.bundleState.collectAsState()
val qdBundleSharedLatest by rememberUpdatedState(qdBundleShared)
var qdBundle by rememberSaveable(qdBundleShared) { mutableStateOf(qdBundleShared) }
val qdBundleLatest by rememberUpdatedState(qdBundle)
LaunchedEffect(qdBundleShared) {
if (qdBundle != qdBundleShared) {
qdBundle = qdBundleShared
}
}
LaunchedEffect(qdBundle) {
delay(Settings.BUNDLE_SAVE_DELAY)
if (qdBundle != qdBundleSharedLatest) {
quickDevices.saveBundle(qdBundle)
}
}
DisposableEffect(Unit) {
onDispose {
taskScope.launch {
quickDevices.saveBundle(qdBundleLatest)
}
}
}
var savedShortcuts by remember {
mutableStateOf(DeviceShortcuts.unmarshalFrom(qdBundle.quickDevicesList))
}
LaunchedEffect(qdBundle.quickDevicesList) {
savedShortcuts = DeviceShortcuts.unmarshalFrom(qdBundle.quickDevicesList)
}
LaunchedEffect(savedShortcuts) {
val serialized = savedShortcuts.marshalToString()
if (serialized != qdBundle.quickDevicesList) {
qdBundle = qdBundle.copy(quickDevicesList = serialized)
}
}
SuperBottomSheet(
show = show,
title = "快速设备排序",
onDismissRequest = onDismissRequest,
) {
ReorderableList(
itemsProvider = {
savedShortcuts.map { device ->
ReorderableList.Item(
id = device.id,
title = device.name.ifBlank { device.host },
subtitle = "${device.host}:${device.port}",
)
}
},
onSettle = { fromIndex, toIndex ->
savedShortcuts = savedShortcuts.move(fromIndex, toIndex)
},
).invoke()
Spacer(Modifier.height(UiSpacing.SheetBottom))
}
}

View File

@@ -1,272 +0,0 @@
package io.github.miuzarte.scrcpyforandroid.pages
import android.content.Intent
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.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.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.core.net.toUri
import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults
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.widgets.SectionSmallTitle
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.ScrollBehavior
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextField
import top.yukonga.miuix.kmp.extra.SuperArrow
import top.yukonga.miuix.kmp.extra.SuperDropdown
import top.yukonga.miuix.kmp.extra.SuperSwitch
import top.yukonga.miuix.kmp.theme.ColorSchemeMode
import kotlin.math.roundToInt
private data class ThemeModeOption(
val label: String,
val mode: ColorSchemeMode,
)
private val THEME_BASE_OPTIONS = listOf(
ThemeModeOption("跟随系统", ColorSchemeMode.System),
ThemeModeOption("浅色", ColorSchemeMode.Light),
ThemeModeOption("深色", ColorSchemeMode.Dark),
)
fun resolveThemeMode(baseIndex: Int, monetEnabled: Boolean): ColorSchemeMode {
return when (baseIndex.coerceIn(0, 2)) {
0 -> if (monetEnabled) ColorSchemeMode.MonetSystem else ColorSchemeMode.System
1 -> if (monetEnabled) ColorSchemeMode.MonetLight else ColorSchemeMode.Light
else -> if (monetEnabled) ColorSchemeMode.MonetDark else ColorSchemeMode.Dark
}
}
private fun resolveThemeLabel(baseIndex: Int, monetEnabled: Boolean): String {
val base = THEME_BASE_OPTIONS.getOrNull(baseIndex.coerceIn(0, 2))?.label ?: "跟随系统"
return if (monetEnabled) "Monet$base" else base
}
@Composable
fun SettingsScreen(
contentPadding: PaddingValues,
themeBaseIndex: Int,
onThemeBaseIndexChange: (Int) -> Unit,
monetEnabled: Boolean,
onMonetEnabledChange: (Boolean) -> Unit,
fullscreenDebugInfoEnabled: Boolean,
onFullscreenDebugInfoEnabledChange: (Boolean) -> Unit,
keepScreenOnWhenStreamingEnabled: Boolean,
onKeepScreenOnWhenStreamingEnabledChange: (Boolean) -> Unit,
devicePreviewCardHeightDp: Int,
onDevicePreviewCardHeightDpChange: (Int) -> Unit,
showFullscreenVirtualButtons: Boolean,
onOpenReorderDevices: () -> Unit,
onOpenVirtualButtonOrder: () -> Unit,
onShowFullscreenVirtualButtonsChange: (Boolean) -> Unit,
customServerUri: String?,
onPickServer: () -> Unit,
onClearServer: () -> Unit,
serverRemotePath: String,
onServerRemotePathChange: (String) -> Unit,
adbKeyName: String,
onAdbKeyNameChange: (String) -> Unit,
adbPairingAutoDiscoverOnDialogOpen: Boolean,
onAdbPairingAutoDiscoverOnDialogOpenChange: (Boolean) -> Unit,
adbAutoReconnectPairedDevice: Boolean,
onAdbAutoReconnectPairedDeviceChange: (Boolean) -> Unit,
scrollBehavior: ScrollBehavior,
) {
val baseModeItems = THEME_BASE_OPTIONS.map { it.label }
val context = LocalContext.current
// 设置
AppPageLazyColumn(
contentPadding = contentPadding,
scrollBehavior = scrollBehavior,
) {
item {
SectionSmallTitle("主题")
Card {
SuperDropdown(
title = "外观模式",
summary = resolveThemeLabel(themeBaseIndex, monetEnabled),
items = baseModeItems,
selectedIndex = themeBaseIndex.coerceIn(0, baseModeItems.lastIndex),
onSelectedIndexChange = onThemeBaseIndexChange,
)
SuperSwitch(
title = "Monet",
summary = "开启后使用 Monet 动态配色",
checked = monetEnabled,
onCheckedChange = onMonetEnabledChange,
)
}
SectionSmallTitle("投屏")
Card {
SuperSwitch(
title = "启用调试信息",
summary = "在全屏界面显示触点数量、设备分辨率和实时 FPS",
checked = fullscreenDebugInfoEnabled,
onCheckedChange = onFullscreenDebugInfoEnabledChange,
)
SuperSwitch(
title = "投屏时保持屏幕常亮",
summary = "Scrcpy 启动后保持本机屏幕常亮,避免锁屏导致 ADB 断开",
checked = keepScreenOnWhenStreamingEnabled,
onCheckedChange = onKeepScreenOnWhenStreamingEnabledChange,
)
SuperSlide(
title = "预览卡高度",
summary = "设备页预览卡高度",
value = devicePreviewCardHeightDp.toFloat(),
onValueChange = {
onDevicePreviewCardHeightDpChange(
it.roundToInt().coerceAtLeast(120)
)
},
valueRange = 160f..600f,
steps = 439,
unit = "dp",
displayFormatter = { it.roundToInt().toString() },
inputInitialValue = devicePreviewCardHeightDp.toString(),
inputFilter = { it.filter(Char::isDigit) },
inputValueRange = 120f..Float.MAX_VALUE,
onInputConfirm = { raw ->
raw.toIntOrNull()
?.let { onDevicePreviewCardHeightDpChange(it.coerceAtLeast(120)) }
},
)
SuperArrow(
title = "快速设备排序",
summary = "手动排序设备页的快速设备",
onClick = onOpenReorderDevices,
)
SuperArrow(
title = "虚拟按钮排序",
summary = "手动排序预览/全屏时的虚拟按钮,并选择哪些按钮展示在外",
onClick = onOpenVirtualButtonOrder,
)
SuperSwitch(
title = "全屏显示虚拟按钮",
summary = "在全屏控制页底部显示返回键、主页键等虚拟按钮",
checked = showFullscreenVirtualButtons,
onCheckedChange = onShowFullscreenVirtualButtonsChange,
)
}
SectionSmallTitle("scrcpy-server")
Card {
Spacer(modifier = Modifier.padding(top = UiSpacing.CardContent))
Text(
text = "自定义 binary",
modifier = Modifier
.padding(horizontal = UiSpacing.CardTitle)
.padding(bottom = UiSpacing.FieldLabelBottom),
fontWeight = FontWeight.Medium,
)
TextField(
value = customServerUri ?: "",
onValueChange = {},
readOnly = true,
label = "scrcpy-server-v3.3.4",
useLabelAsPlaceholder = customServerUri == null,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = UiSpacing.CardContent)
.padding(bottom = UiSpacing.CardContent),
trailingIcon = {
Row(modifier = Modifier.padding(end = UiSpacing.SectionTitleLeadingGap)) {
if (customServerUri != null) IconButton(onClick = onClearServer) {
Icon(Icons.Rounded.Clear, contentDescription = "清空")
}
IconButton(onClick = onPickServer) {
Icon(Icons.Rounded.FileOpen, contentDescription = "选择文件")
}
}
},
)
Text(
text = "Remote Path",
modifier = Modifier
.padding(horizontal = UiSpacing.CardTitle)
.padding(bottom = UiSpacing.FieldLabelBottom),
fontWeight = FontWeight.Medium,
)
TextField(
value = serverRemotePath,
onValueChange = onServerRemotePathChange,
label = AppDefaults.SERVER_REMOTE_PATH,
useLabelAsPlaceholder = true,
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = UiSpacing.CardContent)
.padding(bottom = UiSpacing.CardContent),
)
}
SectionSmallTitle("ADB")
Card {
Text(
text = "自定义 ADB 密钥名",
modifier = Modifier
.padding(horizontal = UiSpacing.CardTitle)
.padding(top = UiSpacing.CardContent, bottom = UiSpacing.FieldLabelBottom),
fontWeight = FontWeight.Medium,
)
TextField(
value = adbKeyName,
onValueChange = onAdbKeyNameChange,
label = AppDefaults.ADB_KEY_NAME,
useLabelAsPlaceholder = true,
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = UiSpacing.CardContent)
.padding(bottom = UiSpacing.CardContent),
)
SuperSwitch(
title = "配对时自动启用发现服务",
summary = "打开配对弹窗后自动搜索可用配对端口",
checked = adbPairingAutoDiscoverOnDialogOpen,
onCheckedChange = onAdbPairingAutoDiscoverOnDialogOpenChange,
)
SuperSwitch(
title = "自动重连已配对设备",
summary = "自动发现开启无线调试的设备,更新快速设备的随机端口并尝试连接(效果比较随缘)",
checked = adbAutoReconnectPairedDevice,
onCheckedChange = onAdbAutoReconnectPairedDeviceChange,
)
}
SectionSmallTitle("关于")
Card {
SuperArrow(
title = "前往仓库",
summary = "github.com/Miuzarte/ScrcpyForAndroid",
onClick = {
val intent = Intent(
Intent.ACTION_VIEW,
"https://github.com/Miuzarte/ScrcpyForAndroid".toUri()
)
context.startActivity(intent)
},
)
}
}
// TODO: 放进 [AppPageLazyColumn] 里
item { Spacer(Modifier.height(UiSpacing.BottomContent)) }
}
}

View File

@@ -0,0 +1,460 @@
package io.github.miuzarte.scrcpyforandroid.pages
import android.content.Intent
import android.os.Process
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.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.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.core.net.toUri
import io.github.miuzarte.scrcpyforandroid.constants.ThemeModes
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
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.services.SnackbarController
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
import io.github.miuzarte.scrcpyforandroid.storage.PreferenceMigration
import io.github.miuzarte.scrcpyforandroid.storage.Settings
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
import io.github.miuzarte.scrcpyforandroid.widgets.SectionSmallTitle
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.ScrollBehavior
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextField
import top.yukonga.miuix.kmp.basic.TopAppBar
import top.yukonga.miuix.kmp.extra.SuperArrow
import top.yukonga.miuix.kmp.extra.SuperDropdown
import top.yukonga.miuix.kmp.extra.SuperSwitch
import top.yukonga.miuix.kmp.theme.ColorSchemeMode
import kotlin.math.roundToInt
import kotlin.system.exitProcess
@Composable
fun SettingsScreen(
scrollBehavior: ScrollBehavior,
snackbar: SnackbarController,
onOpenReorderDevices: () -> Unit,
onOpenVirtualButtonOrder: () -> Unit,
onPickServer: () -> Unit,
) {
Scaffold(
topBar = {
TopAppBar(
title = "设置",
scrollBehavior = scrollBehavior,
)
},
) { pagePadding ->
SettingsPage(
contentPadding = pagePadding,
scrollBehavior = scrollBehavior,
snackbar = snackbar,
onOpenReorderDevices = onOpenReorderDevices,
onOpenVirtualButtonOrder = onOpenVirtualButtonOrder,
onPickServer = onPickServer,
)
}
}
@Composable
fun SettingsPage(
contentPadding: PaddingValues,
scrollBehavior: ScrollBehavior,
snackbar: SnackbarController,
onOpenReorderDevices: () -> Unit,
onOpenVirtualButtonOrder: () -> Unit,
onPickServer: () -> Unit,
) {
val context = LocalContext.current
val appContext = context.applicationContext
var needMigration by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
needMigration = PreferenceMigration(appContext).needsMigration()
}
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
val scope = rememberCoroutineScope()
val asBundleShared by appSettings.bundleState.collectAsState()
val asBundleSharedLatest by rememberUpdatedState(asBundleShared)
var asBundle by rememberSaveable(asBundleShared) { mutableStateOf(asBundleShared) }
val asBundleLatest by rememberUpdatedState(asBundle)
LaunchedEffect(asBundleShared) {
if (asBundle != asBundleShared) {
asBundle = asBundleShared
}
}
LaunchedEffect(asBundle) {
delay(Settings.BUNDLE_SAVE_DELAY)
if (asBundle != asBundleSharedLatest) {
appSettings.saveBundle(asBundle)
}
}
DisposableEffect(Unit) {
onDispose {
taskScope.launch {
appSettings.saveBundle(asBundleLatest)
}
}
}
val themeItems = rememberSaveable { ThemeModes.baseOptions.map { it.label } }
val customServerVersionShowInput = rememberSaveable(asBundle.customServerUri) {
asBundle.customServerUri.isNotBlank()
}
var customServerVersionInput by rememberSaveable(asBundle.customServerVersion) {
mutableStateOf(asBundle.customServerVersion)
}
var serverRemotePathInput by rememberSaveable(asBundle.serverRemotePath) {
mutableStateOf(
if (asBundle.serverRemotePath == AppSettings.SERVER_REMOTE_PATH.defaultValue) ""
else asBundle.serverRemotePath
)
}
var adbKeyNameInput by rememberSaveable(asBundle.adbKeyName) {
mutableStateOf(
if (asBundle.adbKeyName == AppSettings.ADB_KEY_NAME.defaultValue) ""
else asBundle.adbKeyName
)
}
// 设置
LazyColumn(
contentPadding = contentPadding,
scrollBehavior = scrollBehavior,
) {
item {
SectionSmallTitle("主题", showLeadingSpacer = false)
Card {
SuperDropdown(
title = "外观模式",
summary = ThemeModes.baseOptions
.getOrNull(asBundle.themeBaseIndex.coerceIn(0, ThemeModes.baseOptions.lastIndex))
?.label
?: "跟随系统",
items = themeItems,
selectedIndex = asBundle.themeBaseIndex.coerceIn(0, ThemeModes.baseOptions.lastIndex),
onSelectedIndexChange = {
asBundle = asBundle.copy(themeBaseIndex = it)
},
)
SuperSwitch(
title = "Monet",
summary = "开启后使用 Monet 动态配色",
checked = asBundle.monet,
onCheckedChange = {
asBundle = asBundle.copy(monet = it)
},
)
}
}
item {
SectionSmallTitle("投屏")
Card {
SuperSwitch(
title = "启用调试信息",
summary = "在全屏界面悬浮显示分辨率、帧率和触点信息",
checked = asBundle.fullscreenDebugInfo,
onCheckedChange = {
asBundle = asBundle.copy(fullscreenDebugInfo = it)
},
)
SuperSlider(
title = "预览卡高度",
summary = "设备页预览卡高度",
value = asBundle.devicePreviewCardHeightDp.toFloat(),
onValueChange = {
asBundle = asBundle.copy(
devicePreviewCardHeightDp = it.roundToInt().coerceAtLeast(120)
)
},
valueRange = 160f..600f,
steps = 600 - 160 - 2,
unit = "dp",
displayFormatter = { it.roundToInt().toString() },
inputInitialValue = asBundle.devicePreviewCardHeightDp.toString(),
inputFilter = { it.filter(Char::isDigit) },
inputValueRange = 120f..UShort.MAX_VALUE.toFloat(),
onInputConfirm = { input ->
input.toIntOrNull()?.let {
asBundle = asBundle.copy(
devicePreviewCardHeightDp = it.coerceAtLeast(120)
)
}
},
)
SuperArrow(
title = "快速设备排序",
summary = "手动排序设备页的快速设备",
onClick = onOpenReorderDevices,
)
SuperArrow(
title = "虚拟按钮排序",
summary = "手动排序预览/全屏时的虚拟按钮,并选择哪些按钮展示在外",
onClick = onOpenVirtualButtonOrder,
)
SuperSwitch(
title = "全屏显示虚拟按钮",
summary = "在全屏控制页底部显示返回键、主页键等虚拟按钮",
checked = asBundle.showFullscreenVirtualButtons,
onCheckedChange = {
asBundle = asBundle.copy(showFullscreenVirtualButtons = it)
},
)
}
}
item {
SectionSmallTitle("scrcpy-server", showLeadingSpacer = false)
Card {
Column(
modifier = Modifier.padding(vertical = UiSpacing.Large),
verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical),
) {
Column(
modifier = Modifier.padding(horizontal = UiSpacing.Large),
verticalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
) {
Text(
text = "自定义 binary",
fontWeight = FontWeight.Medium,
)
TextField(
value = asBundle.customServerUri,
onValueChange = {},
readOnly = true,
label = Scrcpy.DEFAULT_SERVER_ASSET_NAME,
useLabelAsPlaceholder = true,
modifier = Modifier.fillMaxWidth(),
trailingIcon = {
Row(
modifier = Modifier
.padding(end = UiSpacing.Medium),
) {
if (asBundle.customServerUri.isNotBlank())
IconButton(
onClick = {
asBundle = asBundle.copy(
customServerUri = "",
customServerVersion = "",
)
},
) {
Icon(
imageVector = Icons.Rounded.Clear,
contentDescription = "清空",
)
}
IconButton(onClick = onPickServer) {
Icon(
imageVector = Icons.Rounded.FileOpen,
contentDescription = "选择文件",
)
}
}
},
)
}
AnimatedVisibility(customServerVersionShowInput) {
Column(
modifier = Modifier.padding(horizontal = UiSpacing.Large),
verticalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
) {
Text(
text = "自定义 binary version",
fontWeight = FontWeight.Medium,
)
SuperTextField(
value = customServerVersionInput,
onValueChange = { customServerVersionInput = it },
onFocusLost = {
if (customServerVersionInput == AppSettings.CUSTOM_SERVER_VERSION.defaultValue)
customServerVersionInput = ""
asBundle = asBundle.copy(
customServerVersion = customServerVersionInput
.ifBlank { AppSettings.CUSTOM_SERVER_VERSION.defaultValue }
)
},
label = Scrcpy.DEFAULT_SERVER_VERSION,
useLabelAsPlaceholder = true,
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
}
Column(
modifier = Modifier.padding(horizontal = UiSpacing.Large),
verticalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
) {
Text(
text = "Remote Path",
fontWeight = FontWeight.Medium,
)
SuperTextField(
value = serverRemotePathInput,
onValueChange = { serverRemotePathInput = it },
onFocusLost = {
if (serverRemotePathInput == AppSettings.SERVER_REMOTE_PATH.defaultValue)
serverRemotePathInput = ""
asBundle = asBundle.copy(
serverRemotePath = serverRemotePathInput
.ifBlank { AppSettings.SERVER_REMOTE_PATH.defaultValue }
)
},
label = Scrcpy.DEFAULT_REMOTE_PATH,
useLabelAsPlaceholder = true,
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}
item {
SectionSmallTitle("ADB", showLeadingSpacer = false)
Card {
Column(
modifier = Modifier.padding(vertical = UiSpacing.Large),
verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical),
) {
Column(
modifier = Modifier.padding(horizontal = UiSpacing.Large),
verticalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
) {
Text(
text = "自定义 ADB 密钥名",
fontWeight = FontWeight.Medium,
)
SuperTextField(
value = adbKeyNameInput,
onValueChange = { adbKeyNameInput = it },
onFocusLost = {
if (adbKeyNameInput == AppSettings.ADB_KEY_NAME.defaultValue)
adbKeyNameInput = ""
asBundle = asBundle.copy(
adbKeyName = adbKeyNameInput
.ifBlank { AppSettings.ADB_KEY_NAME.defaultValue }
)
},
label = AppSettings.ADB_KEY_NAME.defaultValue,
useLabelAsPlaceholder = true,
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
}
SuperSwitch(
title = "配对时自动启用发现服务",
summary = "打开配对弹窗后自动搜索可用配对端口",
checked = asBundle.adbPairingAutoDiscoverOnDialogOpen,
onCheckedChange = {
asBundle = asBundle.copy(
adbPairingAutoDiscoverOnDialogOpen = it
)
},
)
SuperSwitch(
title = "自动重连已配对设备",
summary = "自动发现开启无线调试的设备,更新快速设备的随机端口并尝试连接(效果比较随缘)",
checked = asBundle.adbAutoReconnectPairedDevice,
onCheckedChange = {
asBundle = asBundle.copy(
adbAutoReconnectPairedDevice = it
)
},
)
}
}
if (needMigration) item {
// 这部分应该不会显示出来,
// 应用启动时就会执行迁移与旧数据的删除
SectionSmallTitle("应用", showLeadingSpacer = false)
Card {
SuperArrow(
title = "恢复旧版本配置",
summary = "从旧版本的 SharedPreferences 恢复至 DataStore",
onClick = {
scope.launch {
val migration = PreferenceMigration(appContext)
migration.migrate(clearSharedPrefs = false)
snackbar.show("迁移完成,应用将重启")
delay(1000)
val intent = context.packageManager.getLaunchIntentForPackage(
context.packageName
)
intent?.apply {
addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK
or Intent.FLAG_ACTIVITY_CLEAR_TASK
)
}
context.startActivity(intent)
Process.killProcess(Process.myPid())
exitProcess(0)
}
},
)
}
}
item {
SectionSmallTitle("关于", showLeadingSpacer = false)
Card {
SuperArrow(
title = "前往仓库",
summary = "github.com/Miuzarte/ScrcpyForAndroid",
onClick = {
context.startActivity(
Intent(
Intent.ACTION_VIEW,
"https://github.com/Miuzarte/ScrcpyForAndroid".toUri(),
)
)
},
)
}
}
item { Spacer(Modifier.height(UiSpacing.PageBottom)) }
}
}

View File

@@ -1,86 +0,0 @@
package io.github.miuzarte.scrcpyforandroid.pages
import androidx.compose.foundation.layout.PaddingValues
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 io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn
import io.github.miuzarte.scrcpyforandroid.widgets.ReorderableList
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.ScrollBehavior
import top.yukonga.miuix.kmp.extra.SuperSwitch
@Composable
internal fun VirtualButtonOrderPage(
contentPadding: PaddingValues,
scrollBehavior: ScrollBehavior,
layoutString: String,
onLayoutChange: (String) -> Unit,
showPreviewText: Boolean,
onShowPreviewTextChange: (Boolean) -> Unit,
) {
var buttonItems by remember(layoutString) {
mutableStateOf(VirtualButtonActions.parseStoredLayout(layoutString))
}
fun emitChanges() {
onLayoutChange(VirtualButtonActions.encodeStoredLayout(buttonItems))
}
AppPageLazyColumn(
contentPadding = contentPadding,
scrollBehavior = scrollBehavior,
) {
// 按钮显示文本开关
item {
Card {
SuperSwitch(
title = "按钮显示文本",
summary = "超过3个建议关闭只对预览卡下方的虚拟按钮生效",
checked = showPreviewText,
onCheckedChange = onShowPreviewTextChange,
)
}
}
item {
ReorderableList(
itemsProvider = {
buttonItems.map { item ->
val action = item.action
ReorderableList.Item(
id = action.id,
icon = action.icon,
title = if (action.keycode == null) action.title else "${action.title} (${action.keycode})",
subtitle = if (item.showOutside) "显示在外部" else "显示在更多菜单内",
checked = item.showOutside,
checkboxEnabled = action != VirtualButtonAction.MORE,
)
}
},
orientation = ReorderableList.Orientation.Column,
onSettle = { fromIndex, toIndex ->
buttonItems = buttonItems.toMutableList().apply {
add(toIndex, removeAt(fromIndex))
}
emitChanges()
},
showCheckbox = true,
onCheckboxChange = { id, checked ->
buttonItems = buttonItems.map { item ->
if (item.action.id == id) {
item.copy(showOutside = checked)
} else {
item
}
}
emitChanges()
},
)()
}
}
}

View File

@@ -0,0 +1,160 @@
package io.github.miuzarte.scrcpyforandroid.pages
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
import io.github.miuzarte.scrcpyforandroid.storage.Settings
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
import io.github.miuzarte.scrcpyforandroid.widgets.ReorderableList
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.ScrollBehavior
import top.yukonga.miuix.kmp.basic.TopAppBar
import top.yukonga.miuix.kmp.extra.SuperSwitch
@Composable
internal fun VirtualButtonOrderScreen(
onBack: () -> Unit,
scrollBehavior: ScrollBehavior,
) {
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
TopAppBar(
title = "虚拟按钮排序",
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "返回"
)
}
},
scrollBehavior = scrollBehavior,
)
},
) { pagePadding ->
VirtualButtonOrderPage(
contentPadding = pagePadding,
scrollBehavior = scrollBehavior,
)
}
}
@Composable
internal fun VirtualButtonOrderPage(
contentPadding: PaddingValues,
scrollBehavior: ScrollBehavior,
) {
val scope = rememberCoroutineScope()
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
val asBundleShared by appSettings.bundleState.collectAsState()
val asBundleSharedLatest by rememberUpdatedState(asBundleShared)
var asBundle by rememberSaveable(asBundleShared) { mutableStateOf(asBundleShared) }
val asBundleLatest by rememberUpdatedState(asBundle)
LaunchedEffect(asBundleShared) {
if (asBundle != asBundleShared) {
asBundle = asBundleShared
}
}
LaunchedEffect(asBundle) {
delay(Settings.BUNDLE_SAVE_DELAY)
if (asBundle != asBundleSharedLatest) {
appSettings.saveBundle(asBundle)
}
}
DisposableEffect(Unit) {
onDispose {
taskScope.launch {
appSettings.saveBundle(asBundleLatest)
}
}
}
var buttonItems by remember(asBundle.virtualButtonsLayout) {
mutableStateOf(VirtualButtonActions.parseStoredLayout(asBundle.virtualButtonsLayout))
}
LazyColumn(
contentPadding = contentPadding,
scrollBehavior = scrollBehavior,
) {
// 按钮显示文本开关
item {
Card {
SuperSwitch(
title = "按钮显示文本",
summary = "超过3个建议关闭只对预览卡下方的虚拟按钮生效",
checked = asBundle.previewVirtualButtonShowText,
onCheckedChange = {
asBundle = asBundle.copy(previewVirtualButtonShowText = it)
},
)
}
}
item {
ReorderableList(
itemsProvider = {
buttonItems.map { item ->
val action = item.action
ReorderableList.Item(
id = action.id,
icon = action.icon,
title = if (action.keycode == null) action.title else "${action.title} (${action.keycode})",
subtitle = if (item.showOutside) "显示在外部" else "显示在更多菜单内",
checked = item.showOutside,
checkboxEnabled = action != VirtualButtonAction.MORE,
)
}
},
orientation = ReorderableList.Orientation.Column,
onSettle = { fromIndex, toIndex ->
buttonItems = buttonItems.toMutableList().apply {
add(toIndex, removeAt(fromIndex))
}
asBundle = asBundle.copy(
virtualButtonsLayout = VirtualButtonActions.encodeStoredLayout(buttonItems)
)
},
showCheckbox = true,
onCheckboxChange = { id, checked ->
buttonItems = buttonItems.map { item ->
if (item.action.id == id) {
item.copy(showOutside = checked)
} else {
item
}
}
asBundle = asBundle.copy(
virtualButtonsLayout = VirtualButtonActions.encodeStoredLayout(buttonItems)
)
},
)()
}
}
}

View File

@@ -20,7 +20,7 @@ import top.yukonga.miuix.kmp.basic.ScrollBehavior
import top.yukonga.miuix.kmp.utils.overScrollVertical
@Composable
fun AppPageLazyColumn(
fun LazyColumn(
contentPadding: PaddingValues,
scrollBehavior: ScrollBehavior,
modifier: Modifier = Modifier,
@@ -32,18 +32,16 @@ fun AppPageLazyColumn(
content: LazyListScope.() -> Unit,
) {
val focusManager = LocalFocusManager.current
val focusClearModifier = if (clearFocusOnTap) {
Modifier.pointerInput(Unit) {
detectTapGestures(onTap = { focusManager.clearFocus() })
}
} else {
Modifier
}
LazyColumn(
modifier = modifier
.fillMaxSize()
.then(focusClearModifier)
.then(
if (clearFocusOnTap)
Modifier.pointerInput(Unit) {
detectTapGestures(onTap = { focusManager.clearFocus() })
} else Modifier
)
.overScrollVertical()
.nestedScroll(scrollBehavior.nestedScrollConnection)
.padding(contentPadding),

View File

@@ -2,28 +2,30 @@ package io.github.miuzarte.scrcpyforandroid.scaffolds
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import androidx.compose.ui.unit.dp
import top.yukonga.miuix.kmp.basic.ButtonDefaults
import top.yukonga.miuix.kmp.basic.Slider
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextButton
import top.yukonga.miuix.kmp.basic.TextField
import top.yukonga.miuix.kmp.extra.SuperArrow
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,
@@ -39,7 +41,9 @@ fun SuperSlide(
displayFormatter: (Float) -> String = { it.toInt().toString() },
displayText: String? = null,
inputTitle: String = title,
inputHint: String = unit,
inputSummary: String = summary,
inputLabel: String = unit,
useLabelAsPlaceholder: Boolean = false,
inputInitialValue: String = displayFormatter(value),
inputFilter: (String) -> String = { text -> text.filter { it.isDigit() || it == '.' } },
inputValueRange: ClosedFloatingPointRange<Float>? = null,
@@ -59,7 +63,8 @@ fun SuperSlide(
endActions = {
val isZeroState = value == 0f && zeroStateText != null
val valueText =
if (isZeroState) zeroStateText else (displayText ?: displayFormatter(value))
if (isZeroState) zeroStateText
else (displayText ?: displayFormatter(value))
val shouldShowUnit = unit.isNotBlank() && (!isZeroState || showUnitWhenZeroState)
val text = if (shouldShowUnit) "$valueText $unit" else valueText
Text(
@@ -82,60 +87,81 @@ fun SuperSlide(
},
)
if (showInputDialog) {
var valueText by remember(inputInitialValue) { mutableStateOf(inputInitialValue) }
val activeInputRange = inputValueRange ?: valueRange
SuperDialog(
show = true,
onDismissRequest = {
SliderInputDialog(
showDialog = showInputDialog,
title = inputTitle,
summary = inputSummary,
label = inputLabel,
useLabelAsPlaceholder = useLabelAsPlaceholder,
initialValue = inputInitialValue,
inputFilter = inputFilter,
inputValueRange = inputValueRange ?: valueRange,
onDismissRequest = { showInputDialog = false },
onDismissFinished = { holdArrow = false },
onConfirm = { input ->
onInputConfirm(input)
showInputDialog = false
holdArrow = false
},
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
) {
Text(text = inputTitle)
}
TextField(
value = valueText,
onValueChange = { valueText = inputFilter(it) },
label = inputHint,
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier
.fillMaxWidth()
.padding(top = UiSpacing.Large),
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = UiSpacing.Large),
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
}
@Composable
private fun SliderInputDialog(
showDialog: Boolean,
title: String,
summary: String,
label: String = "",
useLabelAsPlaceholder: Boolean = false,
initialValue: String,
inputFilter: (String) -> String,
inputValueRange: ClosedFloatingPointRange<Float>,
onDismissRequest: () -> Unit,
onDismissFinished: () -> Unit,
onConfirm: (String) -> Unit,
) {
SuperDialog(
show = showDialog,
title = title,
summary = summary,
onDismissRequest = onDismissRequest,
onDismissFinished = onDismissFinished,
content = {
var text by rememberSaveable(initialValue) { mutableStateOf(initialValue) }
SuperTextField(
modifier = Modifier.padding(bottom = 16.dp),
value = text,
label = label,
useLabelAsPlaceholder = useLabelAsPlaceholder,
maxLines = 1,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done,
),
onValueChange = { newValue ->
text = inputFilter(newValue)
},
)
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(),
)
}
}
}
},
)
}

View File

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

View File

@@ -0,0 +1,565 @@
package io.github.miuzarte.scrcpyforandroid.scrcpy
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.AudioSource
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.CameraFacing
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.DisplayImePolicy
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.ListOptions
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.LogLevel
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Orientation
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.OrientationLock
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Tick
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.VideoSource
// TODO: 配置切换
data class ClientOptions(
// var serial: String = "", // to server
// --crop=width:height:x:y
var crop: String = "", // to server
// TODO: implement
// --record
var recordFilename: String = "",
// var windowTitle: String = "",
// var pushTarget: String = "",
// var renderDriver: String = "",
// --video-codec-options
var videoCodecOptions: String = "", // to server
// --audio-codec-options
var audioCodecOptions: String = "", // to server
// --video-encoder
var videoEncoder: String = "", // to server
// --audio-encoder
var audioEncoder: String = "", // to server
// --camera-id
var cameraId: String = "", // to server
// --camera-size
var cameraSize: String = "", // to server
// --camera-ar
var cameraAr: String = "", // to server
// --camera-fps
var cameraFps: UShort = 0u, // to server
var logLevel: LogLevel = LogLevel.INFO, // to server
// --video-codec
var videoCodec: Codec = Codec.H264, // to server
// --audio-codec
var audioCodec: Codec = Codec.OPUS, // to server
// --video-source
var videoSource: VideoSource = VideoSource.DISPLAY, // to server
// --audio-source
var audioSource: AudioSource = AudioSource.AUTO, // to server
// --record-format
var recordFormat: RecordFormat = RecordFormat.AUTO,
// var keyboardInputMode: KeyboardInputMode,
// var mouseInputMode: MouseInputMode,
// var gamepadInputMode: GamepadInputMode,
// sc_mouse_bindings(pri, sec)
// var mouseBindings: MouseBindings,
// --camera-facing
var cameraFacing: CameraFacing = CameraFacing.ANY, // to server
// var portRange: PortRange, // sc_port_range(first, last) // to server
// var tunnelHost: UInt, // to server
// var tunnelPort: UShort, // to server
// OR of enum sc_shortcut_mod values
// var shortcutMods: ShortcutMod,
// --max-size
var maxSize: UShort = 0u, // to server
// --video-bit-rate
var videoBitRate: Int = 0, // to server
// --audio-bit-rate
var audioBitRate: Int = 0, // to server
// --max-fps
var maxFps: String = "", // float to be parsed by the server
var angle: String = "", // float to be parsed by the server
// --capture-orientation=.*
var captureOrientation: Orientation = Orientation.ORIENT_0, // to server
// --capture-orientation=@.*
var captureOrientationLock: OrientationLock = OrientationLock.UNLOCKED, // to server
// --display-orientation
var displayOrientation: Orientation = Orientation.ORIENT_0,
// --record-orientation
var recordOrientation: Orientation = Orientation.ORIENT_0,
// --display-ime-policy
var displayImePolicy: DisplayImePolicy = DisplayImePolicy.UNDEFINED, // to server
// var windowX: Short,
// var windowY: Short,
// var windowWidth: UShort,
// var windowHeight: UShort,
// --display-id
// -1 for empty text field
var displayId: Int = -1, // to server
// var videoBuffer: Tick,
// var audioBuffer: Tick,
// var audioOutputBuffer: Tick,
// var timeLimit: Tick,
// --screen-off-timeout
var screenOffTimeout: Tick = Tick(-1), // to server
// var otg: Boolean,
// --show-touches
var showTouches: Boolean = false, // to server
// 开始后立即进入全屏
// --fullscreen
var fullscreen: Boolean = false,
// var alwaysOnTop: Boolean,
// --no-control
var control: Boolean = true, // to server
// --no-playback
// --no-video-playback
var videoPlayback: Boolean = true,
// --no-playback
// --no-audio-playback
var audioPlayback: Boolean = true,
// --turn-screen-off
var turnScreenOff: Boolean = false,
// [sc_key_inject_mode]
// var keyInjectMode: KeyInjectMode,
// var windowBorderless: Boolean,
// var mipmaps: Boolean,
// --stay-awake
var stayAwake: Boolean = false, // to server
// --force-adb-forward
// var forceAdbForward: Boolean, // to server
// --disable-screensaver
var disableScreensaver: Boolean = false,
// var forwardKeyRepeat: Boolean,
// var legacyPaste: Boolean,
// --power-off-on-close
var powerOffOnClose: Boolean = false, // to server
// --no-clipboard-autosync
// var clipboardAutosync: Boolean = true, // to server
// --no-downsize-on-error
// var downsizeOnError: Boolean = true, // to server
// var tcpip: Boolean, // to server
// var tcpipDst: String = "", // to server
// var selectUsb: Boolean, // to server
// var selectTcpip: Boolean, // to server
// --no-cleanup
var cleanUp: Boolean = true, // to server
// var startFpsCounter: Boolean,
// --no-power-on
var powerOn: Boolean = true, // to server
// --no-video
var video: Boolean = true, // to server
// --no-audio
var audio: Boolean = true, // to server
// --require-audio
var requireAudio: Boolean = false,
// 结束 scrcpy 后断开 adb 连接
// --kill-adb-on-close
var killAdbOnClose: Boolean = false, // to server but client side
// --camera-high-speed
var cameraHighSpeed: Boolean = false, // to server
var list: ListOptions = ListOptions.NULL, // to server
// --no-window
// var window: Boolean,
// --no-mouse-hover
// var mouseHover: Boolean = true,
// --audio-dup
var audioDup: Boolean = false, // to server
// --new-display=[<width>x<height>][/<dpi>]
var newDisplay: String = "", // to server
// --start-app
var startApp: String = "",
// --no-vd-destroy-content
var vdDestroyContent: Boolean = true, // to server
// --no-vd-system-decorations
var vdSystemDecorations: Boolean = true, // to server
) {
enum class RecordFormat(val string: String) {
AUTO("auto"), // ignore
MP4("mp4"),
MKV("mkv"),
M4A("m4a"),
MKA("mka"),
OPUS("opus"),
AAC("aac"),
FLAC("flac"),
WAV("wav");
fun isAudioOnly(): Boolean = when (this) {
M4A, MKA, OPUS, AAC, FLAC, WAV -> true
else -> false
}
}
fun fix(): ClientOptions {
when(videoSource) {
VideoSource.DISPLAY -> {
cameraId = ""
cameraFacing = CameraFacing.ANY
cameraSize = ""
cameraAr = ""
cameraFps = 0u
cameraHighSpeed = false
}
VideoSource.CAMERA -> {
displayId = 0
maxSize = 0u
maxFps = ""
newDisplay = ""
crop = ""
}
}
return this
}
fun validate(): ClientOptions {
if (!video) {
videoPlayback = false
powerOn = false
}
if (!audio) {
audioPlayback = false
}
if (video && !videoPlayback && recordFilename.isBlank()) {
video = false
}
if (audio && !audioPlayback && recordFilename.isBlank()) {
audio = false
}
if (!video && !audio && !control) {
throw IllegalArgumentException(
"nothing to do"
)
}
if (!video) {
requireAudio = true
}
if (newDisplay.isNotBlank()) {
if (videoSource != VideoSource.DISPLAY) {
throw IllegalArgumentException(
"--new-display is only available with --video-source=display"
)
}
if (!video) {
throw IllegalArgumentException(
"--new-display is incompatible with --no-video"
)
}
}
if (videoSource == VideoSource.CAMERA) {
if (displayId > 0) {
throw IllegalArgumentException(
"--display-id is only available with --video-source=display"
)
}
if (displayImePolicy != DisplayImePolicy.UNDEFINED) {
throw IllegalArgumentException(
"--display-ime-policy is only available with --video-source=display"
)
}
if (cameraId.isNotBlank() && cameraFacing != CameraFacing.ANY) {
throw IllegalArgumentException(
"Cannot specify both --camera-id and --camera-facing"
)
}
if (cameraSize.isNotBlank()) {
if (maxSize > 0u) {
throw IllegalArgumentException(
"Cannot specify both --camera-size and -m/--max-size"
)
}
if (cameraAr.isNotBlank()) {
throw IllegalArgumentException(
"--camera-high-speed requires an explicit --camera-fps value"
)
}
}
if (cameraHighSpeed && cameraFps > 0u) {
throw IllegalArgumentException(
"--camera-high-speed requires an explicit --camera-fps value"
)
}
if (control) {
control = false
}
} else if (cameraId.isNotBlank()
|| cameraAr.isNotBlank()
|| cameraFacing != CameraFacing.ANY
|| cameraFps > 0u
|| cameraHighSpeed
|| cameraSize.isNotBlank()
) {
throw IllegalArgumentException(
"Camera options are only available with --video-source=camera"
)
}
if (displayId > 0 && newDisplay.isNotBlank()) {
throw IllegalArgumentException(
"Cannot specify both --display-id and --new-display"
)
}
if (displayImePolicy != DisplayImePolicy.UNDEFINED
&& displayId == 0 && newDisplay.isBlank()
) {
throw IllegalArgumentException(
"--display-ime-policy is only supported on a secondary display"
)
}
if (audio && audioSource == AudioSource.AUTO) {
// Select the audio source according to the video source
audioSource = if (videoSource == VideoSource.DISPLAY) {
if (audioDup) {
AudioSource.PLAYBACK
} else {
AudioSource.OUTPUT
}
} else {
AudioSource.MIC
}
}
if (audioDup) {
if (!audio) {
throw IllegalArgumentException(
"--audio-dup not supported if audio is disabled"
)
}
if (audioSource != AudioSource.PLAYBACK) {
throw IllegalArgumentException(
"--audio-dup is specific to --audio-source=playback"
)
}
}
if (recordFormat != RecordFormat.AUTO && recordFilename.isNotBlank()) {
throw IllegalArgumentException(
"Record format specified without recording"
)
}
if (recordFilename.isNotBlank()) {
if (!video && !audio) {
throw IllegalArgumentException(
"Video and audio disabled, nothing to record"
)
}
if (recordFormat == RecordFormat.AUTO) {
// TODO: guess from recordFilename
recordFormat = RecordFormat.MP4
}
if (recordOrientation != Orientation.ORIENT_0) {
if (recordOrientation.isMirror()) {
throw IllegalArgumentException(
"Record orientation only supports rotation, " +
"not flipping: ${recordOrientation.string}"
)
}
}
if (video && recordFormat.isAudioOnly()) {
throw IllegalArgumentException(
"Audio container does not support video stream"
)
}
if (recordFormat == RecordFormat.OPUS && audioCodec != Codec.OPUS) {
throw IllegalArgumentException(
"Recording to OPUS file requires an OPUS audio stream " +
"(try with --audio-codec=opus)"
)
}
if (recordFormat == RecordFormat.AAC && audioCodec != Codec.AAC) {
throw IllegalArgumentException(
"Recording to AAC file requires an AAC audio stream " +
"(try with --audio-codec=aac)"
)
}
if (recordFormat == RecordFormat.FLAC && audioCodec != Codec.FLAC) {
throw IllegalArgumentException(
"Recording to FLAC file requires an FLAC audio stream " +
"(try with --audio-codec=flac)"
)
}
if (recordFormat == RecordFormat.WAV && audioCodec != Codec.RAW) {
throw IllegalArgumentException(
"Recording to WAV file requires an RAW audio stream " +
"(try with --audio-codec=raw)"
)
}
if ((recordFormat == RecordFormat.MP4 || recordFormat == RecordFormat.M4A)
&& audioCodec == Codec.RAW
) {
throw IllegalArgumentException(
"Recording to MP4 container does not support RAW audio"
)
}
}
/*
if (audioCodec == Codec.FLAC && audioBitRate > 0u) {
// "--audio-bit-rate is ignored for FLAC audio codec"
// audioBitRate
}
*/
/*
if (audioCodec == Codec.RAW) {
if (audioBitRate > 0u) {
// "--audio-bit-rate is ignored for raw audio codec"
// audioBitRate
}
if (audioCodecOptions.isNotBlank()) {
// "--audio-codec-options is ignored for raw audio codec"
// audioCodecOptions
}
if (audioEncoder.isNotBlank()) {
// "--audio-encoder is ignored for raw audio codec"
// audioEncoder
}
}
*/
if (!control) {
if (turnScreenOff) {
throw IllegalArgumentException(
"Cannot request to turn screen off if control is disabled"
)
}
if (stayAwake) {
throw IllegalArgumentException(
"Cannot request to stay awake if control is disabled"
)
}
if (showTouches) {
throw IllegalArgumentException(
"Cannot request to show touches if control is disabled"
)
}
if (powerOffOnClose) {
throw IllegalArgumentException(
"Cannot request power off on close if control is disabled"
)
}
if (startApp.isNotBlank()) {
throw IllegalArgumentException(
"Cannot start an Android app if control is disabled"
)
}
}
return this
}
fun toServerParams(scid: UInt): ServerParams {
return ServerParams(
scid = scid,
logLevel = logLevel,
videoCodec = videoCodec,
audioCodec = audioCodec,
videoSource = videoSource,
audioSource = audioSource,
cameraFacing = cameraFacing,
crop = crop,
maxSize = maxSize,
videoBitRate = videoBitRate,
audioBitRate = audioBitRate,
maxFps = maxFps,
angle = angle,
screenOffTimeout = screenOffTimeout,
captureOrientation = captureOrientation,
captureOrientationLock = captureOrientationLock,
control = control,
displayId = displayId,
newDisplay = newDisplay,
displayImePolicy = displayImePolicy,
video = video,
audio = audio,
audioDup = audioDup,
showTouches = showTouches,
stayAwake = stayAwake,
videoCodecOptions = videoCodecOptions,
audioCodecOptions = audioCodecOptions,
videoEncoder = videoEncoder,
audioEncoder = audioEncoder,
cameraId = cameraId,
cameraSize = cameraSize,
cameraAr = cameraAr,
cameraFps = cameraFps,
powerOffOnClose = powerOffOnClose,
cleanUp = cleanUp,
powerOn = powerOn,
// killAdbOnClose == killAdbOnClose, // client side
cameraHighSpeed = cameraHighSpeed,
vdDestroyContent = vdDestroyContent,
vdSystemDecorations = vdSystemDecorations,
list = list,
)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,288 @@
package io.github.miuzarte.scrcpyforandroid.scrcpy
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.AudioSource
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.CameraFacing
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.DisplayImePolicy
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.ListOptions
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.LogLevel
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Orientation
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.OrientationLock
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Tick
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.VideoSource
// 启动 scrcpy 前直接继承自 [ClientOptions],
// 不需要默认值
data class ServerParams(
var scid: UInt, // (random uint32) & 0x7FFFFFFF
// var reqSerial: String,
var logLevel: LogLevel,
var videoCodec: Codec,
var audioCodec: Codec,
var videoSource: VideoSource,
var audioSource: AudioSource,
var cameraFacing: CameraFacing,
var crop: String,
var videoCodecOptions: String,
var audioCodecOptions: String,
var videoEncoder: String,
var audioEncoder: String,
var cameraId: String,
var cameraSize: String,
var cameraAr: String, // aspect ratio
var cameraFps: UShort,
// var portRange: PortRange, // sc_port_range(first, last)
// var tunnelHost: UInt,
// var tunnelPort: UShort,
var maxSize: UShort,
var videoBitRate: Int,
var audioBitRate: Int,
var maxFps: String, // float to be parsed by the server
var angle: String, // float to be parsed by the server
var screenOffTimeout: Tick,
var captureOrientation: Orientation,
var captureOrientationLock: OrientationLock,
var control: Boolean,
var displayId: Int,
var newDisplay: String,
var displayImePolicy: DisplayImePolicy,
var video: Boolean,
var audio: Boolean,
var audioDup: Boolean,
var showTouches: Boolean,
var stayAwake: Boolean,
// var forceAdbForward: Boolean,
var powerOffOnClose: Boolean,
// var downsizeOnError: Boolean,
// var tcpip: Boolean,
// var tcpipDst: String,
// var selectUsb: Boolean,
// var selectTcpip: Boolean,
var cleanUp: Boolean,
var powerOn: Boolean,
// var killAdbOnClose: Boolean,
var cameraHighSpeed: Boolean,
var vdDestroyContent: Boolean,
var vdSystemDecorations: Boolean,
var list: ListOptions,
) {
companion object {
const val SEPARATOR: String = " "
const val ILLEGAL_CHARACTER_SET: String = " ;'\"*$?&`#\\|<>[]{}()!~\r\n"
}
private fun validate(str: String) {
// forbid special shell characters
if (str.any { it in ILLEGAL_CHARACTER_SET }) {
throw IllegalArgumentException("Invalid server param: [$str]")
}
}
fun build(vararg extraArgs: String): String {
return (extraArgs.toList() + this.toList()).joinToString(SEPARATOR)
}
fun toList(simplify: Boolean = false): MutableList<String> {
val cmd = mutableListOf<String>()
if (!simplify) {
cmd.add("scid=${scid.toString(16)}")
cmd.add("log_level=${logLevel.string}")
}
if (!video) {
cmd.add("video=false")
}
if (videoBitRate > 0) {
cmd.add("video_bit_rate=$videoBitRate")
}
if (!audio) {
cmd.add("audio=false")
}
if (audioBitRate > 0) {
if (!audioCodec.isLossless) {
// 比官方实现多个判断编解码器类型
cmd.add("audio_bit_rate=$audioBitRate")
}
}
if (videoCodec != Codec.H264) {
cmd.add("video_codec=${videoCodec.string}")
}
if (audioCodec != Codec.OPUS) {
cmd.add("audio_codec=${audioCodec.string}")
}
if (videoSource != VideoSource.DISPLAY) {
cmd.add("video_source=${videoSource.string}")
}
// audio_source (default: output, only add if different and audio is enabled)
// Note: AUTO should have been resolved by ClientOptions.validate()
if (audioSource != AudioSource.OUTPUT && audio) {
cmd.add("audio_source=${audioSource.string}")
}
if (audioDup) {
cmd.add("audio_dup=true")
}
if (maxSize > 0u) {
cmd.add("max_size=$maxSize")
}
if (maxFps.isNotBlank()) {
validate(maxFps)
cmd.add("max_fps=${maxFps.trim()}")
}
if (angle.isNotBlank()) {
validate(angle)
cmd.add("angle=${angle.trim()}")
}
if (captureOrientationLock != OrientationLock.UNLOCKED
|| captureOrientation != Orientation.ORIENT_0
) {
when (captureOrientationLock) {
OrientationLock.LOCKED_INITIAL ->
cmd.add("capture_orientation=@")
OrientationLock.LOCKED_VALUE ->
cmd.add("capture_orientation=@${captureOrientation.string}")
OrientationLock.UNLOCKED ->
cmd.add("capture_orientation=${captureOrientation.string}")
}
}
// always true cause we are using adb wireless
cmd.add("tunnel_forward=true")
if (crop.isNotBlank()) {
validate(crop)
cmd.add("crop=${crop.trim()}")
}
if (!control) {
// By default, control is true
cmd.add("control=false")
}
if (displayId >= 0) {
cmd.add("display_id=$displayId")
}
if (cameraId.isNotBlank()) {
validate(cameraId)
cmd.add("camera_id=${cameraId.trim()}")
}
if (cameraSize.isNotBlank()) {
validate(cameraSize)
cmd.add("camera_size=${cameraSize.trim()}")
}
if (cameraFacing != CameraFacing.ANY) {
cmd.add("camera_facing=${cameraFacing.string}")
}
if (cameraAr.isNotBlank()) {
validate(cameraAr)
cmd.add("camera_ar=${cameraAr.trim()}")
}
if (cameraFps > 0u) {
cmd.add("camera_fps=$cameraFps")
}
if (cameraHighSpeed) {
cmd.add("camera_high_speed=true")
}
if (showTouches) {
cmd.add("show_touches=true")
}
if (stayAwake) {
cmd.add("stay_awake=true")
}
if (screenOffTimeout.value != -1L) {
require(screenOffTimeout >= 0) {
"screen_off_timeout must be >= 0"
}
cmd.add("screen_off_timeout=${screenOffTimeout.toMs()}")
}
if (videoCodecOptions.isNotBlank()) {
validate(videoCodecOptions)
cmd.add("video_codec_options=${videoCodecOptions.trim()}")
}
if (audioCodecOptions.isNotBlank()) {
validate(audioCodecOptions)
cmd.add("audio_codec_options=${audioCodecOptions.trim()}")
}
if (videoEncoder.isNotBlank()) {
validate(videoEncoder)
cmd.add("video_encoder=${videoEncoder.trim()}")
}
if (audioEncoder.isNotBlank()) {
validate(audioEncoder)
cmd.add("audio_encoder=${audioEncoder.trim()}")
}
if (powerOffOnClose) {
cmd.add("power_off_on_close=true")
}
// not implemented
val clipBoardAutosync = false
if (!clipBoardAutosync) {
cmd.add("clipboard_autosync=false")
}
// not implemented
val downsizeOnError = false
if (!downsizeOnError) {
cmd.add("downsize_on_error=false")
}
if (!cleanUp) {
// By default, cleanup is true
cmd.add("cleanup=false")
}
if (!powerOn) {
// By default, power_on is true
cmd.add("power_on=false")
}
if (newDisplay.isNotBlank()) {
validate(newDisplay)
cmd.add("new_display=${newDisplay.trim()}")
}
if (displayImePolicy != DisplayImePolicy.UNDEFINED) {
cmd.add("display_ime_policy=${displayImePolicy.string}")
}
if (!vdDestroyContent) {
cmd.add("vd_destroy_content=false")
}
if (!vdSystemDecorations) {
cmd.add("vd_system_decorations=false")
}
if (list has ListOptions.ENCODERS) {
cmd.add("list_encoders=true")
}
if (list has ListOptions.DISPLAYS) {
cmd.add("list_displays=true")
}
if (list has ListOptions.CAMERAS) {
cmd.add("list_cameras=true")
}
if (list has ListOptions.CAMERA_SIZES) {
cmd.add("list_camera_sizes=true")
}
if (list has ListOptions.APPS) {
cmd.add("list_apps=true")
}
return cmd
}
}

View File

@@ -0,0 +1,188 @@
package io.github.miuzarte.scrcpyforandroid.scrcpy
import kotlin.time.Duration
import kotlin.time.Duration.Companion.microseconds
/*
参考官方实现尽量统一行为
*/
class Shared {
@JvmInline
value class Tick(val value: Long) {
fun toNs(): Long = value * 1000
fun toUs(): Long = value
fun toMs(): Long = value / 1000
fun toSec(): Long = value / 1_000_000
fun toSecDouble(): Double = value / 1_000_000.0
operator fun plus(other: Tick): Tick = Tick(value + other.value)
operator fun minus(other: Tick): Tick = Tick(value - other.value)
operator fun times(scale: Long): Tick = Tick(value * scale)
operator fun div(scale: Long): Tick = Tick(value / scale)
operator fun compareTo(other: Tick): Int = value.compareTo(other.value)
operator fun compareTo(other: Long): Int = value.compareTo(other)
operator fun compareTo(other: Int): Int = value.compareTo(other.toLong())
companion object {
const val FREQ = 1_000_000 // 微秒/秒
fun fromNs(ns: Long): Tick = Tick(ns / 1000)
fun fromUs(us: Long): Tick = Tick(us)
fun fromMs(ms: Long): Tick = Tick(ms * 1000)
fun fromSec(sec: Long): Tick = Tick(sec * 1_000_000)
fun fromSecDouble(sec: Double): Tick = Tick((sec * 1_000_000).toLong())
fun fromDuration(duration: Duration): Tick = Tick(duration.inWholeMicroseconds)
}
fun toDuration(): Duration = value.microseconds
fun Duration.toTick(): Tick = Tick(this.inWholeMicroseconds)
}
enum class ListOptions(val value: Int) {
NULL(0x0),
ENCODERS(0x1), // --list-encoders
DISPLAYS(0x2), // --list-displays
CAMERAS(0x4), // --list-cameras
CAMERA_SIZES(0x8), // --list-camera-sizes
APPS(0x10); // --list-apps
infix fun or(other: ListOptions) = value or other.value
infix fun and(other: ListOptions) = value and other.value
infix fun has(other: ListOptions) = this and other != 0
}
enum class EncoderType(val s: String) {
HARDWARE("hw"),
SOFTWARE("sw"),
}
enum class LogLevel(val string: String) {
VERBOSE("verbose"),
DEBUG("debug"),
INFO("info"),
WARN("warn"),
ERROR("error");
}
enum class Codec(
val string: String,
val displayName: String,
val type: Type,
val id: Int,
val isLossless: Boolean,
) {
H264("h264", "H.264", Type.VIDEO, 0x68323634, false), // default, ignore when passing
H265("h265", "H.265", Type.VIDEO, 0x68323635, false),
AV1("av1", "AV1", Type.VIDEO, 0x00617631, false),
OPUS("opus", "Opus", Type.AUDIO, 0x6f707573, false), // default, ignore when passing
AAC("aac", "AAC", Type.AUDIO, 0x00616163, false),
FLAC("flac", "FLAC", Type.AUDIO, 0x666c6163, true),
RAW("raw", "RAW", Type.AUDIO, 0x00726177, true); // wav raw
enum class Type { VIDEO, AUDIO }
companion object {
val VIDEO = entries.filter { it.type == Type.VIDEO }
val AUDIO = entries.filter { it.type == Type.AUDIO }
fun fromString(value: String, type: Type = Type.VIDEO) =
entries.find { it.string.equals(value, ignoreCase = true) }
?: when (type) {
Type.VIDEO -> H264
Type.AUDIO -> OPUS
}
fun fromId(value: Int, type: Type? = null): Codec? =
entries.find { it.id == value && (type == null || it.type == type) }
}
}
enum class VideoSource(val string: String) {
DISPLAY("display"), // default, ignore when passing
CAMERA("camera");
companion object {
fun fromString(value: String) =
entries.find { it.string.equals(value, ignoreCase = true) }
?: DISPLAY
}
}
enum class AudioSource(val string: String) {
AUTO("auto"), // OUTPUT for video DISPLAY, MIC for video CAMERA
OUTPUT("output"),
MIC("mic"),
PLAYBACK("playback"),
MIC_UNPROCESSED("mic-unprocessed"),
MIC_CAMCORDER("mic-camcorder"),
MIC_VOICE_RECOGNITION("mic-voice-recognition"),
MIC_VOICE_COMMUNICATION("mic-voice-communication"),
VOICE_CALL("voice-call"),
VOICE_CALL_UPLINK("voice-call-uplink"),
VOICE_CALL_DOWNLINK("voice-call-downlink"),
VOICE_PERFORMANCE("voice-performance");
companion object {
fun fromString(value: String) =
entries.find { it.string.equals(value, ignoreCase = true) }
?: AUTO
}
}
enum class CameraFacing(val string: String) {
ANY("any"), // default, ignore when passing
FRONT("front"),
BACK("back"),
EXTERNAL("external");
companion object {
fun fromString(value: String) =
entries.find { it.string.equals(value, ignoreCase = true) }
?: ANY
}
}
enum class Orientation(val string: String) {
ORIENT_0("0"),
ORIENT_90("90"),
ORIENT_180("180"),
ORIENT_270("270"),
FLIP_0("flip0"),
FLIP_90("flip90"),
FLIP_180("flip180"),
FLIP_270("flip270");
fun isMirror(): Boolean = ordinal and 4 != 0
fun isSwap(): Boolean = ordinal and 1 != 0
fun getRotation(): Orientation {
return when (ordinal and 3) {
0 -> ORIENT_0
1 -> ORIENT_90
2 -> ORIENT_180
3 -> ORIENT_270
else -> error("Invalid rotation value")
}
}
companion object {
fun fromInt(value: Int): Orientation {
return entries.getOrNull(value)
?: error("Invalid orientation value: $value")
}
}
}
enum class OrientationLock(val string: String) {
UNLOCKED("unlocked"), // ignore
LOCKED_VALUE("locked_value"), // "@${orientation.string}"
LOCKED_INITIAL("locked_initial"); // "@"
}
enum class DisplayImePolicy(val string: String) {
UNDEFINED("undefined"), // default, ignore when passing
LOCAL("local"),
FALLBACK("fallback"),
HIDE("hide");
}
}

View File

@@ -0,0 +1,273 @@
package io.github.miuzarte.scrcpyforandroid.scrcpy
import android.util.Log
import android.view.MotionEvent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.IntSize
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
class TouchEventHandler(
private val coroutineScope: CoroutineScope,
private val session: Scrcpy.Session.SessionInfo,
private val touchAreaSize: IntSize,
private val activePointerIds: LinkedHashSet<Int>,
private val activePointerPositions: LinkedHashMap<Int, Offset>,
private val activePointerDevicePositions: LinkedHashMap<Int, Pair<Int, Int>>,
private val pointerLabels: LinkedHashMap<Int, Int>,
private var nextPointerLabel: Int,
private val onInjectTouch: suspend (action: Int, pointerId: Long, x: Int, y: Int, pressure: Float, buttons: Int) -> Unit,
private val onActiveTouchCountChanged: (Int) -> Unit,
private val onActiveTouchDebugChanged: (String) -> Unit,
private val onNextPointerLabelChanged: (Int) -> Unit,
) {
companion object {
private const val FULLSCREEN_TOUCH_LOG_TAG = "FullscreenTouch"
}
private object UiMotionActions {
const val DOWN = 0
const val UP = 1
const val MOVE = 2
const val CANCEL = 3
const val POINTER_DOWN = 5
const val POINTER_UP = 6
}
private val eventPointerIds = HashSet<Int>(10)
private val eventPositions = HashMap<Int, Offset>(10)
private val eventPressures = HashMap<Int, Float>(10)
private val justPressedPointerIds = HashSet<Int>(10)
fun handleMotionEvent(event: MotionEvent): Boolean {
if (touchAreaSize.width == 0 || touchAreaSize.height == 0) {
return true
}
val bounds = calculateContentBounds()
if (event.actionMasked == MotionEvent.ACTION_CANCEL) {
return handleCancelAction(bounds)
}
extractEventData(event)
handleDisappearedPointers(eventPointerIds, bounds)
val endedPointerId = getEndedPointerId(event)
handlePointerDown(event, endedPointerId, bounds)
handlePointerMove(event, endedPointerId, bounds)
handlePointerUp(endedPointerId, bounds)
onActiveTouchCountChanged(activePointerIds.size)
refreshTouchDebug()
return true
}
private data class ContentBounds(
val width: Float,
val height: Float,
val left: Float,
val top: Float,
)
private fun calculateContentBounds(): ContentBounds {
val sessionAspect = if (session.height == 0) {
16f / 9f
} else {
session.width.toFloat() / session.height.toFloat()
}
val containerWidth = touchAreaSize.width.toFloat()
val containerHeight = touchAreaSize.height.toFloat()
val containerAspect = containerWidth / containerHeight
val contentWidth: Float
val contentHeight: Float
if (sessionAspect > containerAspect) {
contentWidth = containerWidth
contentHeight = containerWidth / sessionAspect
} else {
contentHeight = containerHeight
contentWidth = containerHeight * sessionAspect
}
val contentLeft = (containerWidth - contentWidth) / 2f
val contentTop = (containerHeight - contentHeight) / 2f
return ContentBounds(contentWidth, contentHeight, contentLeft, contentTop)
}
private fun isInsideContent(rawX: Float, rawY: Float, bounds: ContentBounds): Boolean {
return rawX in bounds.left..(bounds.left + bounds.width) &&
rawY in bounds.top..(bounds.top + bounds.height)
}
private fun mapToDevice(rawX: Float, rawY: Float, bounds: ContentBounds): Pair<Int, Int> {
val normalizedX = ((rawX - bounds.left) / bounds.width).coerceIn(0f, 1f)
val normalizedY = ((rawY - bounds.top) / bounds.height).coerceIn(0f, 1f)
val x = (normalizedX * (session.width - 1).coerceAtLeast(0)).roundToInt()
.coerceIn(0, (session.width - 1).coerceAtLeast(0))
val y = (normalizedY * (session.height - 1).coerceAtLeast(0)).roundToInt()
.coerceIn(0, (session.height - 1).coerceAtLeast(0))
return x to y
}
private fun getPointerLabel(pointerId: Int): Int {
val existing = pointerLabels[pointerId]
if (existing != null) {
return existing
}
val assigned = nextPointerLabel
nextPointerLabel += 1
onNextPointerLabelChanged(nextPointerLabel)
pointerLabels[pointerId] = assigned
return assigned
}
private fun refreshTouchDebug() {
if (activePointerIds.isEmpty()) {
onActiveTouchDebugChanged("")
return
}
val debug = activePointerIds
.sortedBy { getPointerLabel(it) }
.joinToString(separator = "\n") { pointerId ->
val label = getPointerLabel(pointerId)
val pos = activePointerDevicePositions[pointerId]
if (pos == null) {
"#$label(id=$pointerId):?"
} else {
"#$label(id=$pointerId):${pos.first},${pos.second}"
}
}
onActiveTouchDebugChanged(debug)
}
private fun releasePointer(pointerId: Int, bounds: ContentBounds) {
if (!activePointerIds.contains(pointerId)) return
val pos = activePointerPositions[pointerId] ?: Offset.Zero
val (x, y) = mapToDevice(pos.x, pos.y, bounds)
coroutineScope.launch {
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)
activePointerDevicePositions.remove(pointerId)
pointerLabels.remove(pointerId)
}
private fun handleCancelAction(bounds: ContentBounds): Boolean {
val toCancel = activePointerIds.toList()
for (pointerId in toCancel) {
releasePointer(pointerId, bounds)
}
onActiveTouchCountChanged(activePointerIds.size)
refreshTouchDebug()
return true
}
private fun extractEventData(event: MotionEvent) {
eventPointerIds.clear()
eventPositions.clear()
eventPressures.clear()
for (i in 0 until event.pointerCount) {
val pointerId = event.getPointerId(i)
eventPointerIds += pointerId
eventPositions[pointerId] = Offset(event.getX(i), event.getY(i))
eventPressures[pointerId] = event.getPressure(i).coerceIn(0f, 1f)
}
}
private fun handleDisappearedPointers(eventPointerIds: Set<Int>, bounds: ContentBounds) {
val disappearedPointers = activePointerIds.filter { it !in eventPointerIds }
for (pointerId in disappearedPointers) {
releasePointer(pointerId, bounds)
}
}
private fun getEndedPointerId(event: MotionEvent): Int? {
return when (event.actionMasked) {
MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP -> event.getPointerId(event.actionIndex)
else -> null
}
}
private fun handlePointerDown(
event: MotionEvent,
endedPointerId: Int?,
bounds: ContentBounds,
) {
justPressedPointerIds.clear()
for (i in 0 until event.pointerCount) {
val pointerId = event.getPointerId(i)
if (pointerId == endedPointerId) continue
val raw = eventPositions[pointerId] ?: continue
val pressure = eventPressures[pointerId] ?: 0f
if (!activePointerIds.contains(pointerId)) {
if (!isInsideContent(raw.x, raw.y, bounds)) continue
val (x, y) = mapToDevice(raw.x, raw.y, bounds)
activePointerIds += pointerId
activePointerPositions[pointerId] = raw
activePointerDevicePositions[pointerId] = x to y
justPressedPointerIds += pointerId
coroutineScope.launch {
runCatching {
onInjectTouch(UiMotionActions.DOWN, pointerId.toLong(), x, y, pressure, 0)
}.onFailure { e ->
Log.w(
FULLSCREEN_TOUCH_LOG_TAG,
"handlePointerDown failed for pointerId=$pointerId",
e
)
}
}
}
}
}
private fun handlePointerMove(
event: MotionEvent,
endedPointerId: Int?,
bounds: ContentBounds,
) {
for (i in 0 until event.pointerCount) {
val pointerId = event.getPointerId(i)
if (!activePointerIds.contains(pointerId)) continue
if (pointerId == endedPointerId) continue
if (pointerId in justPressedPointerIds) continue
val raw = eventPositions[pointerId] ?: continue
val pressure = eventPressures[pointerId] ?: 0f
activePointerPositions[pointerId] = raw
val (x, y) = mapToDevice(raw.x, raw.y, bounds)
activePointerDevicePositions[pointerId] = x to y
coroutineScope.launch {
runCatching {
onInjectTouch(UiMotionActions.MOVE, pointerId.toLong(), x, y, pressure, 0)
}.onFailure { e ->
Log.w(
FULLSCREEN_TOUCH_LOG_TAG,
"handlePointerMove failed for pointerId=$pointerId",
e
)
}
}
}
}
private fun handlePointerUp(
endedPointerId: Int?,
bounds: ContentBounds,
) {
if (endedPointerId != null) {
val endPos = eventPositions[endedPointerId]
if (endPos != null) {
activePointerPositions[endedPointerId] = endPos
}
releasePointer(endedPointerId, bounds)
}
}
}

View File

@@ -1,528 +0,0 @@
package io.github.miuzarte.scrcpyforandroid.services
import android.content.Context
import androidx.core.content.edit
import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults
import io.github.miuzarte.scrcpyforandroid.constants.AppPreferenceKeys
internal data class MainSettings(
val audioEnabled: Boolean = AppDefaults.AUDIO_ENABLED,
val audioCodec: String = AppDefaults.AUDIO_CODEC,
val videoCodec: String = AppDefaults.VIDEO_CODEC,
val themeBaseIndex: Int = AppDefaults.THEME_BASE_INDEX,
val monetEnabled: Boolean = AppDefaults.MONET,
val fullscreenDebugInfoEnabled: Boolean = AppDefaults.FULLSCREEN_DEBUG_INFO,
val showFullscreenVirtualButtons: Boolean = AppDefaults.SHOW_FULLSCREEN_VIRTUAL_BUTTONS,
val showPreviewVirtualButtonText: Boolean = AppDefaults.PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT,
val keepScreenOnWhenStreamingEnabled: Boolean = AppDefaults.KEEP_SCREEN_ON_WHEN_STREAMING,
val devicePreviewCardHeightDp: Int = AppDefaults.DEVICE_PREVIEW_CARD_HEIGHT_DP,
val virtualButtonsLayout: String = AppDefaults.VIRTUAL_BUTTONS_LAYOUT,
val customServerUri: String? = AppDefaults.CUSTOM_SERVER_URI,
val serverRemotePath: String = AppDefaults.SERVER_REMOTE_PATH_INPUT,
val adbKeyName: String = AppDefaults.ADB_KEY_NAME_INPUT,
val adbPairingAutoDiscoverOnDialogOpen: Boolean =
AppDefaults.ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN,
val adbAutoReconnectPairedDevice: Boolean = AppDefaults.ADB_AUTO_RECONNECT_PAIRED_DEVICE,
val adbMdnsLanDiscoveryEnabled: Boolean = AppDefaults.ADB_MDNS_LAN_DISCOVERY,
)
internal data class DevicePageSettings(
val quickConnectInput: String = AppDefaults.QUICK_CONNECT_INPUT,
val pairHost: String = AppDefaults.PAIR_HOST,
val pairPort: String = AppDefaults.PAIR_PORT,
val pairCode: String = AppDefaults.PAIR_CODE,
val audioBitRateKbps: Int = AppDefaults.AUDIO_BIT_RATE_KBPS,
val audioBitRateInput: String = AppDefaults.AUDIO_BIT_RATE_INPUT,
val videoBitRateMbps: Float = AppDefaults.VIDEO_BIT_RATE_MBPS,
val videoBitRateInput: String = AppDefaults.VIDEO_BIT_RATE_INPUT,
val turnScreenOff: Boolean = AppDefaults.TURN_SCREEN_OFF,
val noControl: Boolean = AppDefaults.NO_CONTROL,
val noVideo: Boolean = AppDefaults.NO_VIDEO,
val videoSourcePreset: String = AppDefaults.VIDEO_SOURCE_PRESET,
val displayIdInput: String = AppDefaults.DISPLAY_ID,
val cameraIdInput: String = AppDefaults.CAMERA_ID,
val cameraFacingPreset: String = AppDefaults.CAMERA_FACING_PRESET,
val cameraSizePreset: String = AppDefaults.CAMERA_SIZE_PRESET,
val cameraSizeCustom: String = AppDefaults.CAMERA_SIZE_CUSTOM,
val cameraAr: String = AppDefaults.CAMERA_AR,
val cameraFps: String = AppDefaults.CAMERA_FPS,
val cameraHighSpeed: Boolean = AppDefaults.CAMERA_HIGH_SPEED,
val audioSourcePreset: String = AppDefaults.AUDIO_SOURCE_PRESET,
val audioSourceCustom: String = AppDefaults.AUDIO_SOURCE_CUSTOM,
val audioDup: Boolean = AppDefaults.AUDIO_DUP,
val noAudioPlayback: Boolean = AppDefaults.NO_AUDIO_PLAYBACK,
val requireAudio: Boolean = AppDefaults.REQUIRE_AUDIO,
val maxSizeInput: String = AppDefaults.MAX_SIZE_INPUT,
val maxFpsInput: String = AppDefaults.MAX_FPS_INPUT,
val videoEncoder: String = AppDefaults.VIDEO_ENCODER,
val videoCodecOptions: String = AppDefaults.VIDEO_CODEC_OPTION,
val audioEncoder: String = AppDefaults.AUDIO_ENCODER,
val audioCodecOptions: String = AppDefaults.AUDIO_CODEC_OPTION,
val newDisplayWidth: String = AppDefaults.NEW_DISPLAY_WIDTH,
val newDisplayHeight: String = AppDefaults.NEW_DISPLAY_HEIGHT,
val newDisplayDpi: String = AppDefaults.NEW_DISPLAY_DPI,
val cropWidth: String = AppDefaults.CROP_WIDTH,
val cropHeight: String = AppDefaults.CROP_HEIGHT,
val cropX: String = AppDefaults.CROP_X,
val cropY: String = AppDefaults.CROP_Y,
)
internal fun loadMainSettings(context: Context): MainSettings {
val prefs = context.getSharedPreferences(
AppPreferenceKeys.PREFS_NAME,
Context.MODE_PRIVATE,
)
return MainSettings(
audioEnabled = prefs.getBoolean(
AppPreferenceKeys.AUDIO_ENABLED,
AppDefaults.AUDIO_ENABLED,
),
audioCodec = prefs.getString(
AppPreferenceKeys.AUDIO_CODEC,
AppDefaults.AUDIO_CODEC,
).orEmpty().ifBlank { AppDefaults.AUDIO_CODEC },
videoCodec = prefs.getString(
AppPreferenceKeys.VIDEO_CODEC,
AppDefaults.VIDEO_CODEC,
).orEmpty().ifBlank { AppDefaults.VIDEO_CODEC },
themeBaseIndex = prefs.getInt(
AppPreferenceKeys.THEME_BASE_INDEX,
AppDefaults.THEME_BASE_INDEX,
),
monetEnabled = prefs.getBoolean(
AppPreferenceKeys.MONET,
AppDefaults.MONET,
),
fullscreenDebugInfoEnabled = prefs.getBoolean(
AppPreferenceKeys.FULLSCREEN_DEBUG_INFO,
AppDefaults.FULLSCREEN_DEBUG_INFO,
),
showFullscreenVirtualButtons = prefs.getBoolean(
AppPreferenceKeys.SHOW_FULLSCREEN_VIRTUAL_BUTTONS,
AppDefaults.SHOW_FULLSCREEN_VIRTUAL_BUTTONS,
),
showPreviewVirtualButtonText = prefs.getBoolean(
AppPreferenceKeys.PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT,
AppDefaults.PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT,
),
keepScreenOnWhenStreamingEnabled = prefs.getBoolean(
AppPreferenceKeys.KEEP_SCREEN_ON_WHEN_STREAMING,
AppDefaults.KEEP_SCREEN_ON_WHEN_STREAMING,
),
devicePreviewCardHeightDp = prefs.getInt(
AppPreferenceKeys.DEVICE_PREVIEW_CARD_HEIGHT_DP,
AppDefaults.DEVICE_PREVIEW_CARD_HEIGHT_DP,
).coerceAtLeast(120),
virtualButtonsLayout = prefs.getString(
AppPreferenceKeys.VIRTUAL_BUTTONS_LAYOUT,
AppDefaults.VIRTUAL_BUTTONS_LAYOUT,
).orEmpty().ifBlank { AppDefaults.VIRTUAL_BUTTONS_LAYOUT },
customServerUri = prefs.getString(
AppPreferenceKeys.CUSTOM_SERVER_URI,
AppDefaults.CUSTOM_SERVER_URI
).orEmpty().ifBlank { null },
serverRemotePath = prefs.getString(
AppPreferenceKeys.SERVER_REMOTE_PATH,
AppDefaults.SERVER_REMOTE_PATH_INPUT,
).orEmpty(),
adbKeyName = prefs.getString(
AppPreferenceKeys.ADB_KEY_NAME,
AppDefaults.ADB_KEY_NAME_INPUT,
).orEmpty(),
adbPairingAutoDiscoverOnDialogOpen = prefs.getBoolean(
AppPreferenceKeys.ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN,
AppDefaults.ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN,
),
adbAutoReconnectPairedDevice = prefs.getBoolean(
AppPreferenceKeys.ADB_AUTO_RECONNECT_PAIRED_DEVICE,
AppDefaults.ADB_AUTO_RECONNECT_PAIRED_DEVICE,
),
adbMdnsLanDiscoveryEnabled = prefs.getBoolean(
AppPreferenceKeys.ADB_MDNS_LAN_DISCOVERY,
AppDefaults.ADB_MDNS_LAN_DISCOVERY,
),
)
}
internal fun saveMainSettings(context: Context, settings: MainSettings) {
context.getSharedPreferences(
AppPreferenceKeys.PREFS_NAME,
Context.MODE_PRIVATE,
).edit {
putBoolean(
AppPreferenceKeys.AUDIO_ENABLED,
settings.audioEnabled,
)
.putString(
AppPreferenceKeys.AUDIO_CODEC,
settings.audioCodec,
)
.putString(
AppPreferenceKeys.VIDEO_CODEC,
settings.videoCodec,
)
.putInt(
AppPreferenceKeys.THEME_BASE_INDEX,
settings.themeBaseIndex,
)
.putBoolean(
AppPreferenceKeys.MONET,
settings.monetEnabled,
)
.putBoolean(
AppPreferenceKeys.FULLSCREEN_DEBUG_INFO,
settings.fullscreenDebugInfoEnabled,
)
.putBoolean(
AppPreferenceKeys.SHOW_FULLSCREEN_VIRTUAL_BUTTONS,
settings.showFullscreenVirtualButtons,
)
.putBoolean(
AppPreferenceKeys.PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT,
settings.showPreviewVirtualButtonText,
)
.putBoolean(
AppPreferenceKeys.KEEP_SCREEN_ON_WHEN_STREAMING,
settings.keepScreenOnWhenStreamingEnabled,
)
.putInt(
AppPreferenceKeys.DEVICE_PREVIEW_CARD_HEIGHT_DP,
settings.devicePreviewCardHeightDp.coerceAtLeast(120),
)
.putString(
AppPreferenceKeys.VIRTUAL_BUTTONS_LAYOUT,
settings.virtualButtonsLayout,
)
.putString(
AppPreferenceKeys.CUSTOM_SERVER_URI,
settings.customServerUri,
)
.putString(
AppPreferenceKeys.SERVER_REMOTE_PATH,
settings.serverRemotePath,
)
.putString(
AppPreferenceKeys.ADB_KEY_NAME,
settings.adbKeyName,
)
.putBoolean(
AppPreferenceKeys.ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN,
settings.adbPairingAutoDiscoverOnDialogOpen,
)
.putBoolean(
AppPreferenceKeys.ADB_AUTO_RECONNECT_PAIRED_DEVICE,
settings.adbAutoReconnectPairedDevice,
)
.putBoolean(
AppPreferenceKeys.ADB_MDNS_LAN_DISCOVERY,
settings.adbMdnsLanDiscoveryEnabled,
)
}
}
internal fun loadDevicePageSettings(context: Context): DevicePageSettings {
val prefs = context.getSharedPreferences(
AppPreferenceKeys.PREFS_NAME,
Context.MODE_PRIVATE,
)
val audioBitRateKbps = prefs.getInt(
AppPreferenceKeys.AUDIO_BIT_RATE_KBPS,
AppDefaults.AUDIO_BIT_RATE_KBPS,
)
return DevicePageSettings(
quickConnectInput = prefs.getString(
AppPreferenceKeys.QUICK_CONNECT_INPUT,
AppDefaults.QUICK_CONNECT_INPUT,
).orEmpty(),
pairHost = AppDefaults.PAIR_HOST,
pairPort = AppDefaults.PAIR_PORT,
pairCode = AppDefaults.PAIR_CODE,
audioBitRateKbps = audioBitRateKbps,
audioBitRateInput = prefs.getString(
AppPreferenceKeys.AUDIO_BIT_RATE_INPUT,
AppDefaults.AUDIO_BIT_RATE_INPUT,
).orEmpty().ifBlank { audioBitRateKbps.toString() },
videoBitRateMbps = prefs.getFloat(
AppPreferenceKeys.VIDEO_BIT_RATE_MBPS,
AppDefaults.VIDEO_BIT_RATE_MBPS,
),
videoBitRateInput = prefs.getString(
AppPreferenceKeys.VIDEO_BIT_RATE_INPUT,
AppDefaults.VIDEO_BIT_RATE_INPUT
).orEmpty().ifBlank { AppDefaults.VIDEO_BIT_RATE_INPUT },
turnScreenOff = prefs.getBoolean(
AppPreferenceKeys.TURN_SCREEN_OFF,
AppDefaults.TURN_SCREEN_OFF,
),
noControl = prefs.getBoolean(
AppPreferenceKeys.NO_CONTROL,
AppDefaults.NO_CONTROL,
),
noVideo = prefs.getBoolean(
AppPreferenceKeys.NO_VIDEO,
AppDefaults.NO_VIDEO,
),
videoSourcePreset = prefs.getString(
AppPreferenceKeys.VIDEO_SOURCE_PRESET,
AppDefaults.VIDEO_SOURCE_PRESET,
).orEmpty().ifBlank { AppDefaults.VIDEO_SOURCE_PRESET },
displayIdInput = prefs.getString(
AppPreferenceKeys.DISPLAY_ID,
AppDefaults.DISPLAY_ID,
)
.orEmpty(),
cameraIdInput = prefs.getString(
AppPreferenceKeys.CAMERA_ID,
AppDefaults.CAMERA_ID,
)
.orEmpty(),
cameraFacingPreset = prefs.getString(
AppPreferenceKeys.CAMERA_FACING_PRESET,
AppDefaults.CAMERA_FACING_PRESET,
).orEmpty(),
cameraSizePreset = prefs.getString(
AppPreferenceKeys.CAMERA_SIZE_PRESET,
AppDefaults.CAMERA_SIZE_PRESET,
).orEmpty(),
cameraSizeCustom = prefs.getString(
AppPreferenceKeys.CAMERA_SIZE_CUSTOM,
AppDefaults.CAMERA_SIZE_CUSTOM,
).orEmpty(),
cameraAr = prefs.getString(
AppPreferenceKeys.CAMERA_AR,
AppDefaults.CAMERA_AR,
).orEmpty(),
cameraFps = prefs.getString(
AppPreferenceKeys.CAMERA_FPS,
AppDefaults.CAMERA_FPS,
).orEmpty(),
cameraHighSpeed = prefs.getBoolean(
AppPreferenceKeys.CAMERA_HIGH_SPEED,
AppDefaults.CAMERA_HIGH_SPEED,
),
audioSourcePreset = prefs.getString(
AppPreferenceKeys.AUDIO_SOURCE_PRESET,
AppDefaults.AUDIO_SOURCE_PRESET,
).orEmpty().ifBlank { AppDefaults.AUDIO_SOURCE_PRESET },
audioSourceCustom = prefs.getString(
AppPreferenceKeys.AUDIO_SOURCE_CUSTOM,
AppDefaults.AUDIO_SOURCE_CUSTOM,
).orEmpty(),
audioDup = prefs.getBoolean(
AppPreferenceKeys.AUDIO_DUP,
AppDefaults.AUDIO_DUP,
),
noAudioPlayback = prefs.getBoolean(
AppPreferenceKeys.NO_AUDIO_PLAYBACK,
AppDefaults.NO_AUDIO_PLAYBACK,
),
requireAudio = prefs.getBoolean(
AppPreferenceKeys.REQUIRE_AUDIO,
AppDefaults.REQUIRE_AUDIO,
),
maxSizeInput = prefs.getString(
AppPreferenceKeys.MAX_SIZE_INPUT,
AppDefaults.MAX_SIZE_INPUT,
)
.orEmpty(),
maxFpsInput = prefs.getString(
AppPreferenceKeys.MAX_FPS_INPUT,
AppDefaults.MAX_FPS_INPUT,
)
.orEmpty(),
videoEncoder = prefs.getString(
AppPreferenceKeys.VIDEO_ENCODER,
AppDefaults.VIDEO_ENCODER,
)
.orEmpty(),
videoCodecOptions = prefs.getString(
AppPreferenceKeys.VIDEO_CODEC_OPTION,
AppDefaults.VIDEO_CODEC_OPTION,
).orEmpty(),
audioEncoder = prefs.getString(
AppPreferenceKeys.AUDIO_ENCODER,
AppDefaults.AUDIO_ENCODER,
).orEmpty(),
audioCodecOptions = prefs.getString(
AppPreferenceKeys.AUDIO_CODEC_OPTION,
AppDefaults.AUDIO_CODEC_OPTION,
).orEmpty(),
newDisplayWidth = prefs.getString(
AppPreferenceKeys.NEW_DISPLAY_WIDTH,
AppDefaults.NEW_DISPLAY_WIDTH,
).orEmpty(),
newDisplayHeight = prefs.getString(
AppPreferenceKeys.NEW_DISPLAY_HEIGHT,
AppDefaults.NEW_DISPLAY_HEIGHT,
).orEmpty(),
newDisplayDpi = prefs.getString(
AppPreferenceKeys.NEW_DISPLAY_DPI,
AppDefaults.NEW_DISPLAY_DPI,
).orEmpty(),
cropWidth = prefs.getString(
AppPreferenceKeys.CROP_WIDTH,
AppDefaults.CROP_WIDTH,
).orEmpty(),
cropHeight = prefs.getString(
AppPreferenceKeys.CROP_HEIGHT,
AppDefaults.CROP_HEIGHT,
).orEmpty(),
cropX = prefs.getString(
AppPreferenceKeys.CROP_X,
AppDefaults.CROP_X,
).orEmpty(),
cropY = prefs.getString(
AppPreferenceKeys.CROP_Y,
AppDefaults.CROP_Y,
).orEmpty(),
)
}
internal fun saveDevicePageSettings(context: Context, settings: DevicePageSettings) {
context.getSharedPreferences(AppPreferenceKeys.PREFS_NAME, Context.MODE_PRIVATE)
.edit {
remove(AppPreferenceKeys.PAIR_HOST)
.remove(AppPreferenceKeys.PAIR_PORT)
.remove(AppPreferenceKeys.PAIR_CODE)
.putString(
AppPreferenceKeys.QUICK_CONNECT_INPUT,
settings.quickConnectInput,
)
.putInt(
AppPreferenceKeys.AUDIO_BIT_RATE_KBPS,
settings.audioBitRateKbps,
)
.putString(
AppPreferenceKeys.AUDIO_BIT_RATE_INPUT,
settings.audioBitRateInput,
)
.putFloat(
AppPreferenceKeys.VIDEO_BIT_RATE_MBPS,
settings.videoBitRateMbps,
)
.putString(
AppPreferenceKeys.VIDEO_BIT_RATE_INPUT,
settings.videoBitRateInput,
)
.putBoolean(
AppPreferenceKeys.TURN_SCREEN_OFF,
settings.turnScreenOff,
)
.putBoolean(
AppPreferenceKeys.NO_CONTROL,
settings.noControl,
)
.putBoolean(
AppPreferenceKeys.NO_VIDEO,
settings.noVideo,
)
.putString(
AppPreferenceKeys.VIDEO_SOURCE_PRESET,
settings.videoSourcePreset,
)
.putString(
AppPreferenceKeys.DISPLAY_ID,
settings.displayIdInput,
)
.putString(
AppPreferenceKeys.CAMERA_ID,
settings.cameraIdInput,
)
.putString(
AppPreferenceKeys.CAMERA_FACING_PRESET,
settings.cameraFacingPreset,
)
.putString(
AppPreferenceKeys.CAMERA_SIZE_PRESET,
settings.cameraSizePreset,
)
.putString(
AppPreferenceKeys.CAMERA_SIZE_CUSTOM,
settings.cameraSizeCustom,
)
.putString(
AppPreferenceKeys.CAMERA_AR,
settings.cameraAr,
)
.putString(
AppPreferenceKeys.CAMERA_FPS,
settings.cameraFps,
)
.putBoolean(
AppPreferenceKeys.CAMERA_HIGH_SPEED,
settings.cameraHighSpeed,
)
.putString(
AppPreferenceKeys.AUDIO_SOURCE_PRESET,
settings.audioSourcePreset,
)
.putString(
AppPreferenceKeys.AUDIO_SOURCE_CUSTOM,
settings.audioSourceCustom,
)
.putBoolean(
AppPreferenceKeys.AUDIO_DUP,
settings.audioDup,
)
.putBoolean(
AppPreferenceKeys.NO_AUDIO_PLAYBACK,
settings.noAudioPlayback,
)
.putBoolean(
AppPreferenceKeys.REQUIRE_AUDIO,
settings.requireAudio,
)
.putString(
AppPreferenceKeys.MAX_SIZE_INPUT,
settings.maxSizeInput,
)
.putString(
AppPreferenceKeys.MAX_FPS_INPUT,
settings.maxFpsInput,
)
.putString(
AppPreferenceKeys.VIDEO_ENCODER,
settings.videoEncoder,
)
.putString(
AppPreferenceKeys.VIDEO_CODEC_OPTION,
settings.videoCodecOptions,
)
.putString(
AppPreferenceKeys.AUDIO_ENCODER,
settings.audioEncoder,
)
.putString(
AppPreferenceKeys.AUDIO_CODEC_OPTION,
settings.audioCodecOptions,
)
.putString(
AppPreferenceKeys.NEW_DISPLAY_WIDTH,
settings.newDisplayWidth,
)
.putString(
AppPreferenceKeys.NEW_DISPLAY_HEIGHT,
settings.newDisplayHeight,
)
.putString(
AppPreferenceKeys.NEW_DISPLAY_DPI,
settings.newDisplayDpi,
)
.putString(
AppPreferenceKeys.CROP_WIDTH,
settings.cropWidth,
)
.putString(
AppPreferenceKeys.CROP_HEIGHT,
settings.cropHeight,
)
.putString(
AppPreferenceKeys.CROP_X,
settings.cropX,
)
.putString(
AppPreferenceKeys.CROP_Y,
settings.cropY,
)
}
}

View File

@@ -1,6 +1,8 @@
package io.github.miuzarte.scrcpyforandroid.services
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
internal data class ConnectedDeviceInfo(
val model: String,
@@ -16,34 +18,26 @@ 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(
nativeCore: NativeCoreFacade,
internal suspend fun fetchConnectedDeviceInfo(
adbService: NativeAdbService,
host: String,
port: Int
): ConnectedDeviceInfo {
fun prop(name: String): String =
runCatching { nativeCore.adbShell("getprop $name").trim() }.getOrDefault("")
): ConnectedDeviceInfo = withContext(Dispatchers.IO) {
suspend fun prop(name: String): String = runCatching {
adbService.shell("getprop $name").trim()
}.getOrDefault("")
val model = prop("ro.product.model")
val serial = prop("ro.serialno")
val manufacturer = prop("ro.product.manufacturer")
val brand = prop("ro.product.brand")
val device = prop("ro.product.device")
val androidRelease = prop("ro.build.version.release")
val sdkInt = prop("ro.build.version.sdk").toIntOrNull() ?: -1
return ConnectedDeviceInfo(
model = model.ifBlank { "$host:$port" },
serial = serial,
manufacturer = manufacturer,
brand = brand,
device = device,
androidRelease = androidRelease,
sdkInt = sdkInt,
ConnectedDeviceInfo(
model = prop("ro.product.model").ifBlank { "$host:$port" },
serial = prop("ro.serialno"),
manufacturer = prop("ro.product.manufacturer"),
brand = prop("ro.product.brand"),
device = prop("ro.product.device"),
androidRelease = prop("ro.build.version.release"),
sdkInt = prop("ro.build.version.sdk").toIntOrNull() ?: -1,
)
}

View File

@@ -0,0 +1,72 @@
package io.github.miuzarte.scrcpyforandroid.services
import android.util.Log
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.snapshots.SnapshotStateList
import io.github.miuzarte.scrcpyforandroid.constants.Defaults
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Global singleton for event logging.
*
* Manages event logs with timestamp formatting and automatic log rotation.
* Logs are stored in a thread-safe SnapshotStateList for Compose integration.
*/
object EventLogger {
private const val LOG_TAG = "EventLogger"
const val MAX_LINES = 512
private val _eventLog: SnapshotStateList<String> = mutableStateListOf()
/**
* Read-only access to the event log list.
*/
val eventLog: List<String> get() = _eventLog
/**
* Log an event with timestamp and optional error.
*
* @param message The log message
* @param level Log level (Log.INFO, Log.ERROR, Log.WARN, Log.DEBUG)
* @param error Optional throwable for error logging
*/
fun logEvent(message: String, level: Int = Log.INFO, error: Throwable? = null) {
val timestamp = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date())
_eventLog.add(0, "[$timestamp] $message")
// Rotate logs if exceeds max size
if (_eventLog.size > MAX_LINES) {
_eventLog.removeRange(MAX_LINES, _eventLog.size)
}
// Log to Android logcat
when (level) {
Log.ERROR -> if (error != null) Log.e(LOG_TAG, message, error)
else Log.e(LOG_TAG, message)
Log.WARN -> if (error != null) Log.w(LOG_TAG, message, error)
else Log.w(LOG_TAG, message)
Log.DEBUG -> if (error != null) Log.d(LOG_TAG, message, error)
else Log.d(LOG_TAG, message)
else -> if (error != null) Log.i(LOG_TAG, message, error)
else Log.i(LOG_TAG, message)
}
}
/**
* Clear all event logs.
*/
fun clearLogs() {
_eventLog.clear()
}
/**
* Check if there are any logs.
*/
fun hasLogs(): Boolean = _eventLog.isNotEmpty()
}

View File

@@ -1,137 +0,0 @@
package io.github.miuzarte.scrcpyforandroid.services
import android.content.Context
import androidx.core.content.edit
import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults
import io.github.miuzarte.scrcpyforandroid.constants.AppPreferenceKeys
import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut
internal fun loadQuickDevices(context: Context): List<DeviceShortcut> {
val raw = context.getSharedPreferences(AppPreferenceKeys.PREFS_NAME, Context.MODE_PRIVATE)
.getString(AppPreferenceKeys.QUICK_DEVICES, "")
.orEmpty()
if (raw.isBlank()) return emptyList()
val result = mutableListOf<DeviceShortcut>()
raw.lineSequence().forEach { line ->
val parts = line.split("|", limit = 3)
when (parts.size) {
3 -> {
val name = parts[0].trim()
val host = parts[1].trim()
val port = parts[2].trim().toIntOrNull() ?: AppDefaults.ADB_PORT
if (host.isNotBlank()) {
result.add(
DeviceShortcut(
id = "$host:$port",
name = name,
host = host,
port = port,
online = false,
),
)
}
}
2 -> {
// Backward compatibility with old format: name|host:port
val name = parts[0].trim()
val host = parts[1].substringBefore(":").trim()
val port = parts[1].substringAfter(":", AppDefaults.ADB_PORT.toString()).trim()
.toIntOrNull() ?: AppDefaults.ADB_PORT
if (host.isNotBlank()) {
result.add(
DeviceShortcut(
id = "$host:$port",
name = name,
host = host,
port = port,
online = false,
),
)
}
}
}
}
return result
}
internal fun saveQuickDevices(context: Context, quickDevices: List<DeviceShortcut>) {
val raw = quickDevices.joinToString("\n") { "${it.name}|${it.host}|${it.port}" }
context.getSharedPreferences(AppPreferenceKeys.PREFS_NAME, Context.MODE_PRIVATE)
.edit {
putString(AppPreferenceKeys.QUICK_DEVICES, raw)
}
}
internal fun parseQuickTarget(raw: String): ConnectionTarget? {
val value = raw.trim()
if (value.isEmpty()) return null
val host = value.substringBefore(':').trim()
if (host.isEmpty()) return null
val port = value.substringAfter(':', AppDefaults.ADB_PORT.toString()).trim().toIntOrNull()
?: AppDefaults.ADB_PORT
return ConnectionTarget(host, port)
}
internal fun upsertQuickDevice(
context: Context,
quickDevices: MutableList<DeviceShortcut>,
host: String,
port: Int,
online: Boolean,
) {
val id = "$host:$port"
val idx = quickDevices.indexOfFirst { it.id == id }
val existingName = if (idx >= 0) quickDevices[idx].name else ""
val item = DeviceShortcut(
id = id,
name = existingName,
host = host,
port = port,
online = online,
)
if (idx >= 0) quickDevices[idx] = item else quickDevices.add(0, item)
saveQuickDevices(context, quickDevices)
}
internal fun updateQuickDeviceNameIfEmpty(
context: Context,
quickDevices: MutableList<DeviceShortcut>,
host: String,
port: Int,
fallbackName: String,
) {
val idx = quickDevices.indexOfFirst { it.host == host && it.port == port }
if (idx >= 0 && quickDevices[idx].name.isBlank()) {
quickDevices[idx] = quickDevices[idx].copy(name = fallbackName)
saveQuickDevices(context, quickDevices)
}
}
internal fun replaceQuickDevicePort(
context: Context,
quickDevices: MutableList<DeviceShortcut>,
host: String,
oldPort: Int,
newPort: Int,
online: Boolean,
) {
val idx = quickDevices.indexOfFirst { it.host == host && it.port == oldPort }
if (idx < 0) return
val old = quickDevices[idx]
val updated = old.copy(
id = "$host:$newPort",
port = newPort,
online = online,
)
quickDevices[idx] = updated
val dedup = quickDevices.distinctBy { it.id }
quickDevices.clear()
quickDevices.addAll(dedup)
saveQuickDevices(context, quickDevices)
}

View File

@@ -0,0 +1,31 @@
package io.github.miuzarte.scrcpyforandroid.services
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import top.yukonga.miuix.kmp.basic.SnackbarDuration
import top.yukonga.miuix.kmp.basic.SnackbarHostState
class SnackbarController(
private val scope: CoroutineScope,
val hostState: SnackbarHostState,
val defaultActionLabel: String? = null,
val defaultWithDismissAction: Boolean? = null,
val defaultDuration: SnackbarDuration? = null,
) {
fun show(
message: String,
actionLabel: String? = defaultActionLabel,
withDismissAction: Boolean = defaultWithDismissAction ?: true,
duration: SnackbarDuration = defaultDuration ?: SnackbarDuration.Short,
scope: CoroutineScope = this.scope,
) {
scope.launch {
hostState.showSnackbar(
message = message,
actionLabel = actionLabel,
withDismissAction = withDismissAction,
duration = duration,
)
}
}
}

View File

@@ -0,0 +1,51 @@
package io.github.miuzarte.scrcpyforandroid.storage
import android.content.Context
import android.os.Parcelable
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.StateFlow
import kotlinx.parcelize.Parcelize
class AdbClientData(context: Context) : Settings(context, "AdbClient") {
companion object {
val RSA_PRIVATE_KEY = Pair(
stringPreferencesKey("rsa_private_key"),
"",
)
val RSA_PUBLIC_KEY_X509 = Pair(
stringPreferencesKey("rsa_public_key_x509"),
"",
)
}
val rsaPrivateKey by setting(RSA_PRIVATE_KEY)
val rsaPublicKeyX509 by setting(RSA_PUBLIC_KEY_X509)
@Parcelize
data class Bundle(
val rsaPrivateKey: String,
val rsaPublicKeyX509: String,
) : Parcelable {
}
private val bundleFields = arrayOf(
bundleField(RSA_PRIVATE_KEY) { bundle: Bundle -> bundle.rsaPrivateKey },
bundleField(RSA_PUBLIC_KEY_X509) { bundle: Bundle -> bundle.rsaPublicKeyX509 },
)
val bundleState: StateFlow<Bundle> = createBundleState(::bundleFromPreferences)
private fun bundleFromPreferences(preferences: Preferences) = Bundle(
rsaPrivateKey = preferences.read(RSA_PRIVATE_KEY),
rsaPublicKeyX509 = preferences.read(RSA_PUBLIC_KEY_X509),
)
suspend fun loadBundle() = loadBundle(::bundleFromPreferences)
suspend fun saveBundle(new: Bundle) = saveBundle(bundleState.value, new, bundleFields)
suspend fun updateBundle(transform: (Bundle) -> Bundle) {
saveBundle(transform(bundleState.value))
}
}

View File

@@ -0,0 +1,162 @@
package io.github.miuzarte.scrcpyforandroid.storage
import android.content.Context
import android.os.Parcelable
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import kotlinx.coroutines.flow.StateFlow
import kotlinx.parcelize.Parcelize
class AppSettings(context: Context) : Settings(context, "AppSettings") {
companion object {
val THEME_BASE_INDEX = Pair(
intPreferencesKey("theme_base_index"),
0
)
val MONET = Pair(
booleanPreferencesKey("monet"),
false
)
val FULLSCREEN_DEBUG_INFO = Pair(
booleanPreferencesKey("fullscreen_debug_info"),
false
)
val SHOW_FULLSCREEN_VIRTUAL_BUTTONS = Pair(
booleanPreferencesKey("show_fullscreen_virtual_buttons"),
true
)
val DEVICE_PREVIEW_CARD_HEIGHT_DP = Pair(
intPreferencesKey("device_preview_card_height_dp"),
320
)
val PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT = Pair(
booleanPreferencesKey("preview_virtual_button_show_text"),
true
)
val VIRTUAL_BUTTONS_LAYOUT = Pair(
stringPreferencesKey("virtual_buttons_layout"),
"more:1,app_switch:1,home:0,back:1,menu:0,notification:0,volume_up:0,volume_down:0,volume_mute:0,power:0,screenshot:0"
)
val CUSTOM_SERVER_URI = Pair(
stringPreferencesKey("custom_server_uri"),
""
)
val CUSTOM_SERVER_VERSION = Pair(
stringPreferencesKey("custom_server_version"),
""
)
val SERVER_REMOTE_PATH = Pair(
stringPreferencesKey("server_remote_path"),
Scrcpy.DEFAULT_REMOTE_PATH,
)
val ADB_KEY_NAME = Pair(
stringPreferencesKey("adb_key_name"),
"scrcpy"
)
val ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN = Pair(
booleanPreferencesKey("adb_pairing_auto_discover_on_dialog_open"),
true
)
val ADB_AUTO_RECONNECT_PAIRED_DEVICE = Pair(
booleanPreferencesKey("adb_auto_reconnect_paired_device"),
true
)
val ADB_MDNS_LAN_DISCOVERY = Pair(
booleanPreferencesKey("adb_mdns_lan_discovery"),
true
)
}
// Theme Settings
val themeBaseIndex by setting(THEME_BASE_INDEX)
val monet by setting(MONET)
// Scrcpy Settings
val fullscreenDebugInfo by setting(FULLSCREEN_DEBUG_INFO)
val showFullscreenVirtualButtons by setting(SHOW_FULLSCREEN_VIRTUAL_BUTTONS)
val devicePreviewCardHeightDp by setting(DEVICE_PREVIEW_CARD_HEIGHT_DP)
val previewVirtualButtonShowText by setting(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT)
val virtualButtonsLayout by setting(VIRTUAL_BUTTONS_LAYOUT)
// Scrcpy Server Settings
val customServerUri by setting(CUSTOM_SERVER_URI)
val customServerVersion by setting(CUSTOM_SERVER_VERSION)
val serverRemotePath by setting(SERVER_REMOTE_PATH)
// ADB Settings
val adbKeyName by setting(ADB_KEY_NAME)
val adbPairingAutoDiscoverOnDialogOpen by setting(ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN)
val adbAutoReconnectPairedDevice by setting(ADB_AUTO_RECONNECT_PAIRED_DEVICE)
val adbMdnsLanDiscovery by setting(ADB_MDNS_LAN_DISCOVERY)
@Parcelize
data class Bundle(
val themeBaseIndex: Int,
val monet: Boolean,
val fullscreenDebugInfo: Boolean,
val showFullscreenVirtualButtons: Boolean,
val devicePreviewCardHeightDp: Int,
val previewVirtualButtonShowText: Boolean,
val virtualButtonsLayout: String,
val customServerUri: String,
val customServerVersion: String,
val serverRemotePath: String,
val adbKeyName: String,
val adbPairingAutoDiscoverOnDialogOpen: Boolean,
val adbAutoReconnectPairedDevice: Boolean,
val adbMdnsLanDiscovery: Boolean,
) : Parcelable {
}
private val bundleFields = arrayOf(
bundleField(THEME_BASE_INDEX) { bundle: Bundle -> bundle.themeBaseIndex },
bundleField(MONET) { bundle: Bundle -> bundle.monet },
bundleField(FULLSCREEN_DEBUG_INFO) { bundle: Bundle -> bundle.fullscreenDebugInfo },
bundleField(SHOW_FULLSCREEN_VIRTUAL_BUTTONS) { bundle: Bundle -> bundle.showFullscreenVirtualButtons },
bundleField(DEVICE_PREVIEW_CARD_HEIGHT_DP) { bundle: Bundle -> bundle.devicePreviewCardHeightDp },
bundleField(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT) { bundle: Bundle -> bundle.previewVirtualButtonShowText },
bundleField(VIRTUAL_BUTTONS_LAYOUT) { bundle: Bundle -> bundle.virtualButtonsLayout },
bundleField(CUSTOM_SERVER_URI) { bundle: Bundle -> bundle.customServerUri },
bundleField(CUSTOM_SERVER_VERSION) { bundle: Bundle -> bundle.customServerVersion },
bundleField(SERVER_REMOTE_PATH) { bundle: Bundle -> bundle.serverRemotePath },
bundleField(ADB_KEY_NAME) { bundle: Bundle -> bundle.adbKeyName },
bundleField(ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN) { bundle: Bundle -> bundle.adbPairingAutoDiscoverOnDialogOpen },
bundleField(ADB_AUTO_RECONNECT_PAIRED_DEVICE) { bundle: Bundle -> bundle.adbAutoReconnectPairedDevice },
bundleField(ADB_MDNS_LAN_DISCOVERY) { bundle: Bundle -> bundle.adbMdnsLanDiscovery },
)
val bundleState: StateFlow<Bundle> = createBundleState(::bundleFromPreferences)
private fun bundleFromPreferences(preferences: Preferences) = Bundle(
themeBaseIndex = preferences.read(THEME_BASE_INDEX),
monet = preferences.read(MONET),
fullscreenDebugInfo = preferences.read(FULLSCREEN_DEBUG_INFO),
showFullscreenVirtualButtons = preferences.read(SHOW_FULLSCREEN_VIRTUAL_BUTTONS),
devicePreviewCardHeightDp = preferences.read(DEVICE_PREVIEW_CARD_HEIGHT_DP),
previewVirtualButtonShowText = preferences.read(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT),
virtualButtonsLayout = preferences.read(VIRTUAL_BUTTONS_LAYOUT),
customServerUri = preferences.read(CUSTOM_SERVER_URI),
customServerVersion = preferences.read(CUSTOM_SERVER_VERSION),
serverRemotePath = preferences.read(SERVER_REMOTE_PATH),
adbKeyName = preferences.read(ADB_KEY_NAME),
adbPairingAutoDiscoverOnDialogOpen = preferences.read(
ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN
),
adbAutoReconnectPairedDevice = preferences.read(ADB_AUTO_RECONNECT_PAIRED_DEVICE),
adbMdnsLanDiscovery = preferences.read(ADB_MDNS_LAN_DISCOVERY),
)
suspend fun loadBundle() = loadBundle(::bundleFromPreferences)
suspend fun saveBundle(new: Bundle) = saveBundle(bundleState.value, new, bundleFields)
suspend fun updateBundle(transform: (Bundle) -> Bundle) {
saveBundle(transform(bundleState.value))
}
// TODO?
// fun validate(): Boolean = true
}

View File

@@ -0,0 +1,564 @@
package io.github.miuzarte.scrcpyforandroid.storage
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import androidx.core.content.edit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
private const val TAG = "PreferenceMigration"
/**
* 从旧的 SharedPreferences 迁移到新的 DataStore
*/
class PreferenceMigration(private val appContext: Context) {
private val appSharedPrefs: SharedPreferences by lazy {
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
}
private val sharedPrefs: SharedPreferences by lazy {
appContext.getSharedPreferences("nativecore_adb_rsa", Context.MODE_PRIVATE)
}
/**
* 检查是否需要迁移SharedPreferences 是否包含数据)
*/
suspend fun needsMigration(): Boolean = withContext(Dispatchers.IO) {
appSharedPrefs.all.isNotEmpty() || sharedPrefs.all.isNotEmpty()
}
/**
* 执行完整迁移
*/
suspend fun migrate(
clearSharedPrefs: Boolean = false,
) = withContext(Dispatchers.IO) {
if (!needsMigration()) {
Log.i(TAG, "No data to migrate, skipping")
return@withContext
} else {
val appList = appSharedPrefs.all.entries.joinToString { (k, v) -> "\"$k\" to $v" }
val adbList = sharedPrefs.all.entries.joinToString { (k, v) -> "\"$k\" to $v" }
Log.d(TAG, "Migrating appSharedPrefs ($appList)")
}
Log.i(TAG, "Starting migration from SharedPreferences to DataStore")
// 迁移 AppSettings
migrateAppSettings()
// 迁移 ScrcpyOptions
migrateScrcpyOptions()
// 迁移 QuickDevices
migrateQuickDevices()
// 迁移 ADB 密钥
migrateAdbClientData()
// 清空 SharedPreferences
if (clearSharedPrefs) {
appSharedPrefs.edit { clear() }
sharedPrefs.edit { clear() }
Log.d(TAG, "SharedPreferences cleared")
}
Log.i(
TAG, "Migration completed successfully" +
" and SharedPreferences ${if (clearSharedPrefs) "" else "is not "}cleared"
)
}
/**
* 迁移应用设置
*/
private suspend fun migrateAppSettings() {
val appSettings = Storage.appSettings
// Theme Settings
migrateInt(
THEME_BASE_INDEX,
AppSettings.THEME_BASE_INDEX.defaultValue,
appSettings.themeBaseIndex,
)
migrateBoolean(
MONET,
AppSettings.MONET.defaultValue,
appSettings.monet,
)
// Scrcpy Settings
migrateBoolean(
FULLSCREEN_DEBUG_INFO,
AppSettings.FULLSCREEN_DEBUG_INFO.defaultValue,
appSettings.fullscreenDebugInfo,
)
migrateBoolean(
SHOW_FULLSCREEN_VIRTUAL_BUTTONS,
AppSettings.SHOW_FULLSCREEN_VIRTUAL_BUTTONS.defaultValue,
appSettings.showFullscreenVirtualButtons,
)
migrateInt(
DEVICE_PREVIEW_CARD_HEIGHT_DP,
AppSettings.DEVICE_PREVIEW_CARD_HEIGHT_DP.defaultValue,
appSettings.devicePreviewCardHeightDp,
)
migrateBoolean(
PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT,
AppSettings.PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT.defaultValue,
appSettings.previewVirtualButtonShowText,
)
migrateString(
VIRTUAL_BUTTONS_LAYOUT,
AppSettings.VIRTUAL_BUTTONS_LAYOUT.defaultValue,
appSettings.virtualButtonsLayout,
)
// Scrcpy Server Settings
migrateString(
CUSTOM_SERVER_URI,
AppSettings.CUSTOM_SERVER_URI.defaultValue,
appSettings.customServerUri,
)
migrateString(
SERVER_REMOTE_PATH,
AppSettings.SERVER_REMOTE_PATH.defaultValue,
appSettings.serverRemotePath,
)
// ADB Settings
migrateString(
ADB_KEY_NAME,
AppSettings.ADB_KEY_NAME.defaultValue,
appSettings.adbKeyName,
)
migrateBoolean(
ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN,
AppSettings.ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN.defaultValue,
appSettings.adbPairingAutoDiscoverOnDialogOpen,
)
migrateBoolean(
ADB_AUTO_RECONNECT_PAIRED_DEVICE,
AppSettings.ADB_AUTO_RECONNECT_PAIRED_DEVICE.defaultValue,
appSettings.adbAutoReconnectPairedDevice,
)
migrateBoolean(
ADB_MDNS_LAN_DISCOVERY,
AppSettings.ADB_MDNS_LAN_DISCOVERY.defaultValue,
appSettings.adbMdnsLanDiscovery,
)
Log.d(TAG, "AppSettings migration completed")
}
/**
* 迁移 Scrcpy 选项
*/
private suspend fun migrateScrcpyOptions() {
val scrcpyOptions = Storage.scrcpyOptions
// Audio & Video Codecs
migrateString(
AUDIO_CODEC,
ScrcpyOptions.AUDIO_CODEC.defaultValue,
scrcpyOptions.audioCodec,
)
migrateString(
VIDEO_CODEC,
ScrcpyOptions.VIDEO_CODEC.defaultValue,
scrcpyOptions.videoCodec,
)
// Bit Rates
val audioBitRateKbps = sharedPrefs.getInt(
AUDIO_BIT_RATE_KBPS,
ScrcpyOptions.AUDIO_BIT_RATE.defaultValue / 1_000,
)
scrcpyOptions.audioBitRate.set(audioBitRateKbps * 1_000) // Convert to bps
val videoBitRateMbps = sharedPrefs.getFloat(
VIDEO_BIT_RATE_MBPS,
(ScrcpyOptions.VIDEO_BIT_RATE.defaultValue / 1_000_000).toFloat(),
)
scrcpyOptions.videoBitRate.set((videoBitRateMbps * 1_000_000).toInt())
// Control Options
migrateBoolean(
TURN_SCREEN_OFF,
ScrcpyOptions.TURN_SCREEN_OFF.defaultValue,
scrcpyOptions.turnScreenOff,
)
migrateBoolean(
NO_CONTROL,
!ScrcpyOptions.CONTROL.defaultValue,
) { value ->
scrcpyOptions.control.set(!value) // Invert logic
}
migrateBoolean(
NO_VIDEO,
!ScrcpyOptions.VIDEO.defaultValue,
) { value ->
scrcpyOptions.video.set(!value) // Invert logic
}
// Video Source
val videoSourcePreset = sharedPrefs.getString(
VIDEO_SOURCE_PRESET,
ScrcpyOptions.VIDEO_SOURCE.defaultValue,
).orEmpty().ifBlank { ScrcpyOptions.VIDEO_SOURCE.defaultValue }
scrcpyOptions.videoSource.set(videoSourcePreset)
migrateString(
DISPLAY_ID,
ScrcpyOptions.DISPLAY_ID.defaultValue.toString(),
) { value ->
value.toIntOrNull()?.let { scrcpyOptions.displayId.set(it) }
}
// Camera Settings
migrateString(
CAMERA_ID,
ScrcpyOptions.CAMERA_ID.defaultValue,
scrcpyOptions.cameraId,
)
migrateString(
CAMERA_FACING_PRESET,
ScrcpyOptions.CAMERA_FACING.defaultValue,
scrcpyOptions.cameraFacing,
)
migrateString(
CAMERA_SIZE_PRESET,
ScrcpyOptions.CAMERA_SIZE.defaultValue,
) { value ->
if (value == "custom") {
val customSize = sharedPrefs.getString(
CAMERA_SIZE_CUSTOM,
ScrcpyOptions.CAMERA_SIZE_CUSTOM.defaultValue,
).orEmpty()
scrcpyOptions.cameraSizeCustom.set(customSize)
scrcpyOptions.cameraSizeUseCustom.set(true)
} else {
scrcpyOptions.cameraSize.set(value)
}
}
migrateString(
CAMERA_AR,
ScrcpyOptions.CAMERA_AR.defaultValue,
scrcpyOptions.cameraAr,
)
migrateString(
CAMERA_FPS,
ScrcpyOptions.CAMERA_FPS.defaultValue.toString(),
) { value ->
value.toIntOrNull()?.let { scrcpyOptions.cameraFps.set(it) }
}
migrateBoolean(
CAMERA_HIGH_SPEED,
ScrcpyOptions.CAMERA_HIGH_SPEED.defaultValue,
scrcpyOptions.cameraHighSpeed,
)
// Audio Source
val audioSourcePreset = sharedPrefs.getString(
AUDIO_SOURCE_PRESET,
"auto",
).orEmpty().ifBlank { "auto" }
if (audioSourcePreset == "custom") {
val customSource = sharedPrefs.getString(
AUDIO_SOURCE_CUSTOM,
ScrcpyOptions.AUDIO_SOURCE.defaultValue,
).orEmpty()
scrcpyOptions.audioSource.set(customSource)
} else {
scrcpyOptions.audioSource.set(audioSourcePreset)
}
migrateBoolean(
AUDIO_DUP,
ScrcpyOptions.AUDIO_DUP.defaultValue,
scrcpyOptions.audioDup,
)
migrateBoolean(
NO_AUDIO_PLAYBACK,
!ScrcpyOptions.AUDIO_PLAYBACK.defaultValue,
) { value ->
scrcpyOptions.audioPlayback.set(!value) // Invert logic
}
migrateBoolean(
REQUIRE_AUDIO,
ScrcpyOptions.REQUIRE_AUDIO.defaultValue,
scrcpyOptions.requireAudio,
)
// Max Size & FPS
migrateString(
MAX_SIZE_INPUT,
ScrcpyOptions.MAX_SIZE.defaultValue.toString(),
) { value ->
value.toIntOrNull()?.let { scrcpyOptions.maxSize.set(it) }
}
migrateString(
MAX_FPS_INPUT,
ScrcpyOptions.MAX_FPS.defaultValue,
scrcpyOptions.maxFps,
)
// Encoders & Codec Options
migrateString(
VIDEO_ENCODER,
ScrcpyOptions.VIDEO_ENCODER.defaultValue,
scrcpyOptions.videoEncoder,
)
migrateString(
VIDEO_CODEC_OPTION,
ScrcpyOptions.VIDEO_CODEC_OPTIONS.defaultValue,
scrcpyOptions.videoCodecOptions,
)
migrateString(
AUDIO_ENCODER,
ScrcpyOptions.AUDIO_ENCODER.defaultValue,
scrcpyOptions.audioEncoder,
)
migrateString(
AUDIO_CODEC_OPTION,
ScrcpyOptions.AUDIO_CODEC_OPTIONS.defaultValue,
scrcpyOptions.audioCodecOptions,
)
// New Display
val newDisplayWidth = sharedPrefs.getString(
NEW_DISPLAY_WIDTH,
"",
).orEmpty()
val newDisplayHeight = sharedPrefs.getString(
NEW_DISPLAY_HEIGHT,
"",
).orEmpty()
val newDisplayDpi = sharedPrefs.getString(
NEW_DISPLAY_DPI,
"",
).orEmpty()
if (newDisplayWidth.isNotBlank() && newDisplayHeight.isNotBlank()) {
val newDisplay = if (newDisplayDpi.isNotBlank()) {
"${newDisplayWidth}x${newDisplayHeight}/${newDisplayDpi}"
} else {
"${newDisplayWidth}x${newDisplayHeight}"
}
scrcpyOptions.newDisplay.set(newDisplay)
}
// Crop
val cropWidth = sharedPrefs.getString(
CROP_WIDTH,
"",
).orEmpty()
val cropHeight = sharedPrefs.getString(
CROP_HEIGHT,
"",
).orEmpty()
val cropX = sharedPrefs.getString(
CROP_X,
"",
).orEmpty()
val cropY = sharedPrefs.getString(
CROP_Y,
"",
).orEmpty()
if (cropWidth.isNotBlank() && cropHeight.isNotBlank()
&& cropX.isNotBlank() && cropY.isNotBlank()
) {
scrcpyOptions.crop.set("${cropWidth}:${cropHeight}:${cropX}:${cropY}")
}
migrateBoolean(
AUDIO_ENABLED,
ScrcpyOptions.AUDIO.defaultValue,
scrcpyOptions.audio,
)
Log.d(TAG, "ScrcpyOptions migration completed")
}
/**
* 迁移快速设备列表
*/
private suspend fun migrateQuickDevices() {
val quickDevices = Storage.quickDevices
// Migrate quick devices list
val quickDevicesRaw = appSharedPrefs.getString(
QUICK_DEVICES,
""
).orEmpty()
if (quickDevicesRaw.isNotBlank()) {
quickDevices.quickDevicesList.set(quickDevicesRaw)
}
// Migrate quick connect input
migrateString(
QUICK_CONNECT_INPUT,
QuickDevices.QUICK_CONNECT_INPUT.defaultValue,
quickDevices.quickConnectInput,
)
Log.d(TAG, "QuickDevices migration completed")
}
/**
* 迁移 ADB 客户端数据RSA 密钥)
*/
private suspend fun migrateAdbClientData() {
val adbClientData = Storage.adbClientData
// 迁移 RSA 私钥
val privKey = sharedPrefs.getString("priv", null)
if (privKey != null) {
adbClientData.rsaPrivateKey.set(privKey)
Log.d(TAG, "ADB RSA private key migrated")
}
Log.d(TAG, "AdbClientData migration completed")
}
// Helper methods for different data types
private suspend fun migrateString(
key: String,
defaultValue: String,
settingProperty: Settings.SettingProperty<String>
) {
val value = appSharedPrefs.getString(key, defaultValue)
.orEmpty()
.ifBlank { defaultValue }
settingProperty.set(value)
}
private suspend fun migrateString(
key: String,
defaultValue: String,
action: suspend (String) -> Unit
) {
val value = appSharedPrefs.getString(key, defaultValue)
.orEmpty()
.ifBlank { defaultValue }
action(value)
}
private suspend fun migrateInt(
key: String,
defaultValue: Int,
settingProperty: Settings.SettingProperty<Int>
) {
val value = appSharedPrefs.getInt(key, defaultValue)
settingProperty.set(value)
}
private suspend fun migrateInt(
key: String,
defaultValue: Int,
action: suspend (Int) -> Unit
) {
val value = appSharedPrefs.getInt(key, defaultValue)
action(value)
}
private suspend fun migrateBoolean(
key: String,
defaultValue: Boolean,
settingProperty: Settings.SettingProperty<Boolean>
) {
val value = appSharedPrefs.getBoolean(key, defaultValue)
settingProperty.set(value)
}
private suspend fun migrateBoolean(
key: String,
defaultValue: Boolean,
action: suspend (Boolean) -> Unit
) {
val value = appSharedPrefs.getBoolean(key, defaultValue)
action(value)
}
companion object {
const val PREFS_NAME = "scrcpy_app_prefs"
// Devices
const val QUICK_DEVICES = "quick_devices"
const val QUICK_CONNECT_INPUT = "quick_connect_input"
const val PAIR_HOST = "pair_host"
const val PAIR_PORT = "pair_port"
const val PAIR_CODE = "pair_code"
const val AUDIO_ENABLED = "audio_enabled"
const val AUDIO_CODEC = "audio_codec"
const val AUDIO_BIT_RATE_INPUT = "audio_bit_rate_input"
const val AUDIO_BIT_RATE_KBPS = "audio_bit_rate_kbps"
const val VIDEO_CODEC = "video_codec"
const val VIDEO_BIT_RATE_MBPS = "video_bit_rate_mbps"
const val VIDEO_BIT_RATE_INPUT = "video_bit_rate_input"
const val TURN_SCREEN_OFF = "turn_screen_off"
const val NO_CONTROL = "no_control"
const val NO_VIDEO = "no_video"
const val VIDEO_SOURCE_PRESET = "video_source_preset"
const val DISPLAY_ID = "display_id"
const val CAMERA_ID = "camera_id"
const val CAMERA_FACING_PRESET = "camera_facing_preset"
const val CAMERA_SIZE_PRESET = "camera_size_preset"
const val CAMERA_SIZE_CUSTOM = "camera_size_custom"
const val CAMERA_AR = "camera_ar"
const val CAMERA_FPS = "camera_fps"
const val CAMERA_HIGH_SPEED = "camera_high_speed"
const val AUDIO_SOURCE_PRESET = "audio_source_preset"
const val AUDIO_SOURCE_CUSTOM = "audio_source_custom"
const val AUDIO_DUP = "audio_dup"
const val NO_AUDIO_PLAYBACK = "no_audio_playback"
const val REQUIRE_AUDIO = "require_audio"
const val MAX_SIZE_INPUT = "max_size_input"
const val MAX_FPS_INPUT = "max_fps_input"
const val VIDEO_ENCODER = "video_encoder"
const val VIDEO_CODEC_OPTION = "video_codec_options"
const val AUDIO_ENCODER = "audio_encoder"
const val AUDIO_CODEC_OPTION = "audio_codec_options"
const val NEW_DISPLAY_WIDTH = "new_display_width"
const val NEW_DISPLAY_HEIGHT = "new_display_height"
const val NEW_DISPLAY_DPI = "new_display_dpi"
const val CROP_WIDTH = "crop_width"
const val CROP_HEIGHT = "crop_height"
const val CROP_X = "crop_x"
const val CROP_Y = "crop_y"
// Settings
const val THEME_BASE_INDEX = "theme_base_index"
const val MONET = "monet"
const val FULLSCREEN_DEBUG_INFO = "fullscreen_debug_info"
const val SHOW_FULLSCREEN_VIRTUAL_BUTTONS = "show_fullscreen_virtual_buttons"
const val DEVICE_PREVIEW_CARD_HEIGHT_DP = "device_preview_card_height_dp"
const val PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT = "preview_virtual_button_show_text"
const val VIRTUAL_BUTTONS_LAYOUT = "virtual_buttons_layout"
const val CUSTOM_SERVER_URI = "custom_server_uri"
const val SERVER_REMOTE_PATH = "server_remote_path"
const val ADB_KEY_NAME = "adb_key_name"
const val ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN =
"adb_pairing_auto_discover_on_dialog_open"
const val ADB_AUTO_RECONNECT_PAIRED_DEVICE = "adb_auto_reconnect_paired_device"
const val ADB_MDNS_LAN_DISCOVERY = "adb_mdns_lan_discovery"
}
}

View File

@@ -0,0 +1,51 @@
package io.github.miuzarte.scrcpyforandroid.storage
import android.content.Context
import android.os.Parcelable
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.StateFlow
import kotlinx.parcelize.Parcelize
class QuickDevices(context: Context) : Settings(context, "QuickDevices") {
companion object {
val QUICK_DEVICES_LIST = Pair(
stringPreferencesKey("quick_devices_list"),
"",
)
val QUICK_CONNECT_INPUT = Pair(
stringPreferencesKey("quick_connect_input"),
"",
)
}
val quickDevicesList by setting(QUICK_DEVICES_LIST)
val quickConnectInput by setting(QUICK_CONNECT_INPUT)
@Parcelize
data class Bundle(
val quickDevicesList: String,
val quickConnectInput: String,
) : Parcelable {
}
private val bundleFields = arrayOf(
bundleField(QUICK_DEVICES_LIST) { bundle: Bundle -> bundle.quickDevicesList },
bundleField(QUICK_CONNECT_INPUT) { bundle: Bundle -> bundle.quickConnectInput },
)
val bundleState: StateFlow<Bundle> = createBundleState(::bundleFromPreferences)
private fun bundleFromPreferences(preferences: Preferences) = Bundle(
quickDevicesList = preferences.read(QUICK_DEVICES_LIST),
quickConnectInput = preferences.read(QUICK_CONNECT_INPUT),
)
suspend fun loadBundle() = loadBundle(::bundleFromPreferences)
suspend fun saveBundle(new: Bundle) = saveBundle(bundleState.value, new, bundleFields)
suspend fun updateBundle(transform: (Bundle) -> Bundle) {
saveBundle(transform(bundleState.value))
}
}

View File

@@ -0,0 +1,540 @@
package io.github.miuzarte.scrcpyforandroid.storage
import android.content.Context
import android.os.Parcelable
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.AudioSource
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.CameraFacing
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.DisplayImePolicy
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.ListOptions
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.LogLevel
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Orientation
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.OrientationLock
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Tick
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.VideoSource
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.runBlocking
import kotlinx.parcelize.Parcelize
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,
)
val LOG_LEVEL = Pair(
stringPreferencesKey("log_level"),
"info",
)
val VIDEO_CODEC = Pair(
stringPreferencesKey("video_codec"),
"h264",
)
val AUDIO_CODEC = Pair(
stringPreferencesKey("audio_codec"),
"opus",
)
val VIDEO_SOURCE = Pair(
stringPreferencesKey("video_source"),
"display",
)
val AUDIO_SOURCE = Pair(
stringPreferencesKey("audio_source"),
"output",
)
val RECORD_FORMAT = Pair(
stringPreferencesKey("record_format"),
"auto",
)
val CAMERA_FACING = Pair(
stringPreferencesKey("camera_facing"),
"any",
)
val MAX_SIZE = Pair(
intPreferencesKey("max_size"),
0,
)
val VIDEO_BIT_RATE = Pair(
intPreferencesKey("video_bit_rate"),
0,
)
val AUDIO_BIT_RATE = Pair(
intPreferencesKey("audio_bit_rate"),
0,
)
val MAX_FPS = Pair(
stringPreferencesKey("max_fps"),
"",
)
val ANGLE = Pair(
stringPreferencesKey("angle"),
"",
)
val CAPTURE_ORIENTATION = Pair(
intPreferencesKey("capture_orientation"),
0,
)
val CAPTURE_ORIENTATION_LOCK = Pair(
stringPreferencesKey("capture_orientation_lock"),
"unlocked",
)
val DISPLAY_ORIENTATION = Pair(
intPreferencesKey("display_orientation"),
0,
)
val RECORD_ORIENTATION = Pair(
intPreferencesKey("record_orientation"),
0,
)
val DISPLAY_IME_POLICY = Pair(
stringPreferencesKey("display_ime_policy"),
"undefined",
)
val DISPLAY_ID = Pair(
intPreferencesKey("display_id"),
-1, // undefined
)
val SCREEN_OFF_TIMEOUT = Pair(
longPreferencesKey("screen_off_timeout"),
-1,
)
val SHOW_TOUCHES = Pair(
booleanPreferencesKey("show_touches"),
false,
)
val FULLSCREEN = Pair(
booleanPreferencesKey("fullscreen"),
false,
)
val CONTROL = Pair(
booleanPreferencesKey("control"),
true,
)
val VIDEO_PLAYBACK = Pair(
booleanPreferencesKey("video_playback"),
true,
)
val AUDIO_PLAYBACK = Pair(
booleanPreferencesKey("audio_playback"),
true,
)
val TURN_SCREEN_OFF = Pair(
booleanPreferencesKey("turn_screen_off"),
false,
)
val STAY_AWAKE = Pair(
booleanPreferencesKey("stay_awake"),
false,
)
val DISABLE_SCREENSAVER = Pair(
booleanPreferencesKey("disable_screensaver"),
false,
)
val POWER_OFF_ON_CLOSE = Pair(
booleanPreferencesKey("power_off_on_close"),
false,
)
val CLEANUP = Pair(
booleanPreferencesKey("cleanup"),
true,
)
val POWER_ON = Pair(
booleanPreferencesKey("power_on"),
true,
)
val VIDEO = Pair(
booleanPreferencesKey("video"),
true,
)
val AUDIO = Pair(
booleanPreferencesKey("audio"),
true,
)
val REQUIRE_AUDIO = Pair(
booleanPreferencesKey("require_audio"),
false,
)
val KILL_ADB_ON_CLOSE = Pair(
booleanPreferencesKey("kill_adb_on_close"),
false,
)
val CAMERA_HIGH_SPEED = Pair(
booleanPreferencesKey("camera_high_speed"),
false,
)
val LIST = Pair(
stringPreferencesKey("list"),
"null",
)
val AUDIO_DUP = Pair(
booleanPreferencesKey("audio_dup"),
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,
)
val VD_SYSTEM_DECORATIONS = Pair(
booleanPreferencesKey("vd_system_decorations"),
true,
)
}
val crop by setting(CROP)
val recordFilename by setting(RECORD_FILENAME)
val videoCodecOptions by setting(VIDEO_CODEC_OPTIONS)
val audioCodecOptions by setting(AUDIO_CODEC_OPTIONS)
val videoEncoder by setting(VIDEO_ENCODER)
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)
val videoCodec by setting(VIDEO_CODEC)
val audioCodec by setting(AUDIO_CODEC)
val videoSource by setting(VIDEO_SOURCE)
val audioSource by setting(AUDIO_SOURCE)
val recordFormat by setting(RECORD_FORMAT)
val cameraFacing by setting(CAMERA_FACING)
val maxSize by setting(MAX_SIZE)
val videoBitRate by setting(VIDEO_BIT_RATE)
val audioBitRate by setting(AUDIO_BIT_RATE)
val maxFps by setting(MAX_FPS)
val angle by setting(ANGLE)
val captureOrientation by setting(CAPTURE_ORIENTATION)
val captureOrientationLock by setting(CAPTURE_ORIENTATION_LOCK)
val displayOrientation by setting(DISPLAY_ORIENTATION)
val recordOrientation by setting(RECORD_ORIENTATION)
val displayImePolicy by setting(DISPLAY_IME_POLICY)
val displayId by setting(DISPLAY_ID)
val screenOffTimeout by setting(SCREEN_OFF_TIMEOUT)
val showTouches by setting(SHOW_TOUCHES)
val fullscreen by setting(FULLSCREEN)
val control by setting(CONTROL)
val videoPlayback by setting(VIDEO_PLAYBACK)
val audioPlayback by setting(AUDIO_PLAYBACK)
val turnScreenOff by setting(TURN_SCREEN_OFF)
val stayAwake by setting(STAY_AWAKE)
val disableScreensaver by setting(DISABLE_SCREENSAVER)
val powerOffOnClose by setting(POWER_OFF_ON_CLOSE)
val cleanup by setting(CLEANUP)
val powerOn by setting(POWER_ON)
val video by setting(VIDEO)
val audio by setting(AUDIO)
val requireAudio by setting(REQUIRE_AUDIO)
val killAdbOnClose by setting(KILL_ADB_ON_CLOSE)
val cameraHighSpeed by setting(CAMERA_HIGH_SPEED)
val list by setting(LIST)
val audioDup by setting(AUDIO_DUP)
val newDisplay by setting(NEW_DISPLAY)
val startApp by setting(START_APP)
val vdDestroyContent by setting(VD_DESTROY_CONTENT)
val vdSystemDecorations by setting(VD_SYSTEM_DECORATIONS)
@Parcelize
data class Bundle(
val crop: String,
val recordFilename: String,
val videoCodecOptions: String,
val audioCodecOptions: String,
val videoEncoder: String,
val audioEncoder: String,
val cameraId: String,
val cameraSize: String,
val cameraSizeCustom: String,
val cameraSizeUseCustom: Boolean,
val cameraAr: String,
val cameraFps: Int,
val logLevel: String,
val videoCodec: String,
val audioCodec: String,
val videoSource: String,
val audioSource: String,
val recordFormat: String,
val cameraFacing: String,
val maxSize: Int,
val videoBitRate: Int,
val audioBitRate: Int,
val maxFps: String,
val angle: String,
val captureOrientation: Int,
val captureOrientationLock: String,
val displayOrientation: Int,
val recordOrientation: Int,
val displayImePolicy: String,
val displayId: Int,
val screenOffTimeout: Long,
val showTouches: Boolean,
val fullscreen: Boolean,
val control: Boolean,
val videoPlayback: Boolean,
val audioPlayback: Boolean,
val turnScreenOff: Boolean,
val stayAwake: Boolean,
val disableScreensaver: Boolean,
val powerOffOnClose: Boolean,
val cleanup: Boolean,
val powerOn: Boolean,
val video: Boolean,
val audio: Boolean,
val requireAudio: Boolean,
val killAdbOnClose: Boolean,
val cameraHighSpeed: Boolean,
val list: String,
val audioDup: Boolean,
val newDisplay: String,
val startApp: String,
val vdDestroyContent: Boolean,
val vdSystemDecorations: Boolean
) : Parcelable {
}
private val bundleFields = arrayOf(
bundleField(CROP) { bundle: Bundle -> bundle.crop },
bundleField(RECORD_FILENAME) { bundle: Bundle -> bundle.recordFilename },
bundleField(VIDEO_CODEC_OPTIONS) { bundle: Bundle -> bundle.videoCodecOptions },
bundleField(AUDIO_CODEC_OPTIONS) { bundle: Bundle -> bundle.audioCodecOptions },
bundleField(VIDEO_ENCODER) { bundle: Bundle -> bundle.videoEncoder },
bundleField(AUDIO_ENCODER) { bundle: Bundle -> bundle.audioEncoder },
bundleField(CAMERA_ID) { bundle: Bundle -> bundle.cameraId },
bundleField(CAMERA_SIZE) { bundle: Bundle -> bundle.cameraSize },
bundleField(CAMERA_SIZE_CUSTOM) { bundle: Bundle -> bundle.cameraSizeCustom },
bundleField(CAMERA_SIZE_USE_CUSTOM) { bundle: Bundle -> bundle.cameraSizeUseCustom },
bundleField(CAMERA_AR) { bundle: Bundle -> bundle.cameraAr },
bundleField(CAMERA_FPS) { bundle: Bundle -> bundle.cameraFps },
bundleField(LOG_LEVEL) { bundle: Bundle -> bundle.logLevel },
bundleField(VIDEO_CODEC) { bundle: Bundle -> bundle.videoCodec },
bundleField(AUDIO_CODEC) { bundle: Bundle -> bundle.audioCodec },
bundleField(VIDEO_SOURCE) { bundle: Bundle -> bundle.videoSource },
bundleField(AUDIO_SOURCE) { bundle: Bundle -> bundle.audioSource },
bundleField(RECORD_FORMAT) { bundle: Bundle -> bundle.recordFormat },
bundleField(CAMERA_FACING) { bundle: Bundle -> bundle.cameraFacing },
bundleField(MAX_SIZE) { bundle: Bundle -> bundle.maxSize },
bundleField(VIDEO_BIT_RATE) { bundle: Bundle -> bundle.videoBitRate },
bundleField(AUDIO_BIT_RATE) { bundle: Bundle -> bundle.audioBitRate },
bundleField(MAX_FPS) { bundle: Bundle -> bundle.maxFps },
bundleField(ANGLE) { bundle: Bundle -> bundle.angle },
bundleField(CAPTURE_ORIENTATION) { bundle: Bundle -> bundle.captureOrientation },
bundleField(CAPTURE_ORIENTATION_LOCK) { bundle: Bundle -> bundle.captureOrientationLock },
bundleField(DISPLAY_ORIENTATION) { bundle: Bundle -> bundle.displayOrientation },
bundleField(RECORD_ORIENTATION) { bundle: Bundle -> bundle.recordOrientation },
bundleField(DISPLAY_IME_POLICY) { bundle: Bundle -> bundle.displayImePolicy },
bundleField(DISPLAY_ID) { bundle: Bundle -> bundle.displayId },
bundleField(SCREEN_OFF_TIMEOUT) { bundle: Bundle -> bundle.screenOffTimeout },
bundleField(SHOW_TOUCHES) { bundle: Bundle -> bundle.showTouches },
bundleField(FULLSCREEN) { bundle: Bundle -> bundle.fullscreen },
bundleField(CONTROL) { bundle: Bundle -> bundle.control },
bundleField(VIDEO_PLAYBACK) { bundle: Bundle -> bundle.videoPlayback },
bundleField(AUDIO_PLAYBACK) { bundle: Bundle -> bundle.audioPlayback },
bundleField(TURN_SCREEN_OFF) { bundle: Bundle -> bundle.turnScreenOff },
bundleField(STAY_AWAKE) { bundle: Bundle -> bundle.stayAwake },
bundleField(DISABLE_SCREENSAVER) { bundle: Bundle -> bundle.disableScreensaver },
bundleField(POWER_OFF_ON_CLOSE) { bundle: Bundle -> bundle.powerOffOnClose },
bundleField(CLEANUP) { bundle: Bundle -> bundle.cleanup },
bundleField(POWER_ON) { bundle: Bundle -> bundle.powerOn },
bundleField(VIDEO) { bundle: Bundle -> bundle.video },
bundleField(AUDIO) { bundle: Bundle -> bundle.audio },
bundleField(REQUIRE_AUDIO) { bundle: Bundle -> bundle.requireAudio },
bundleField(KILL_ADB_ON_CLOSE) { bundle: Bundle -> bundle.killAdbOnClose },
bundleField(CAMERA_HIGH_SPEED) { bundle: Bundle -> bundle.cameraHighSpeed },
bundleField(LIST) { bundle: Bundle -> bundle.list },
bundleField(AUDIO_DUP) { bundle: Bundle -> bundle.audioDup },
bundleField(NEW_DISPLAY) { bundle: Bundle -> bundle.newDisplay },
bundleField(START_APP) { bundle: Bundle -> bundle.startApp },
bundleField(VD_DESTROY_CONTENT) { bundle: Bundle -> bundle.vdDestroyContent },
bundleField(VD_SYSTEM_DECORATIONS) { bundle: Bundle -> bundle.vdSystemDecorations },
)
val bundleState: StateFlow<Bundle> = createBundleState(::bundleFromPreferences)
private fun bundleFromPreferences(preferences: Preferences) = Bundle(
crop = preferences.read(CROP),
recordFilename = preferences.read(RECORD_FILENAME),
videoCodecOptions = preferences.read(VIDEO_CODEC_OPTIONS),
audioCodecOptions = preferences.read(AUDIO_CODEC_OPTIONS),
videoEncoder = preferences.read(VIDEO_ENCODER),
audioEncoder = preferences.read(AUDIO_ENCODER),
cameraId = preferences.read(CAMERA_ID),
cameraSize = preferences.read(CAMERA_SIZE),
cameraSizeCustom = preferences.read(CAMERA_SIZE_CUSTOM),
cameraSizeUseCustom = preferences.read(CAMERA_SIZE_USE_CUSTOM),
cameraAr = preferences.read(CAMERA_AR),
cameraFps = preferences.read(CAMERA_FPS),
logLevel = preferences.read(LOG_LEVEL),
videoCodec = preferences.read(VIDEO_CODEC),
audioCodec = preferences.read(AUDIO_CODEC),
videoSource = preferences.read(VIDEO_SOURCE),
audioSource = preferences.read(AUDIO_SOURCE),
recordFormat = preferences.read(RECORD_FORMAT),
cameraFacing = preferences.read(CAMERA_FACING),
maxSize = preferences.read(MAX_SIZE),
videoBitRate = preferences.read(VIDEO_BIT_RATE),
audioBitRate = preferences.read(AUDIO_BIT_RATE),
maxFps = preferences.read(MAX_FPS),
angle = preferences.read(ANGLE),
captureOrientation = preferences.read(CAPTURE_ORIENTATION),
captureOrientationLock = preferences.read(CAPTURE_ORIENTATION_LOCK),
displayOrientation = preferences.read(DISPLAY_ORIENTATION),
recordOrientation = preferences.read(RECORD_ORIENTATION),
displayImePolicy = preferences.read(DISPLAY_IME_POLICY),
displayId = preferences.read(DISPLAY_ID),
screenOffTimeout = preferences.read(SCREEN_OFF_TIMEOUT),
showTouches = preferences.read(SHOW_TOUCHES),
fullscreen = preferences.read(FULLSCREEN),
control = preferences.read(CONTROL),
videoPlayback = preferences.read(VIDEO_PLAYBACK),
audioPlayback = preferences.read(AUDIO_PLAYBACK),
turnScreenOff = preferences.read(TURN_SCREEN_OFF),
stayAwake = preferences.read(STAY_AWAKE),
disableScreensaver = preferences.read(DISABLE_SCREENSAVER),
powerOffOnClose = preferences.read(POWER_OFF_ON_CLOSE),
cleanup = preferences.read(CLEANUP),
powerOn = preferences.read(POWER_ON),
video = preferences.read(VIDEO),
audio = preferences.read(AUDIO),
requireAudio = preferences.read(REQUIRE_AUDIO),
killAdbOnClose = preferences.read(KILL_ADB_ON_CLOSE),
cameraHighSpeed = preferences.read(CAMERA_HIGH_SPEED),
list = preferences.read(LIST),
audioDup = preferences.read(AUDIO_DUP),
newDisplay = preferences.read(NEW_DISPLAY),
startApp = preferences.read(START_APP),
vdDestroyContent = preferences.read(VD_DESTROY_CONTENT),
vdSystemDecorations = preferences.read(VD_SYSTEM_DECORATIONS),
)
suspend fun loadBundle() = loadBundle(::bundleFromPreferences)
suspend fun saveBundle(new: Bundle) = saveBundle(bundleState.value, new, bundleFields)
suspend fun updateBundle(transform: (Bundle) -> Bundle) {
saveBundle(transform(bundleState.value))
}
fun validate(): Boolean = runBlocking {
runCatching {
toClientOptions().validate()
true
}.getOrDefault(false)
}
// TODO: 处理空值
fun toClientOptions() = toClientOptions(bundleState.value)
fun toClientOptions(bundle: Bundle) = ClientOptions(
crop = bundle.crop,
recordFilename = bundle.recordFilename,
videoCodecOptions = bundle.videoCodecOptions,
audioCodecOptions = bundle.audioCodecOptions,
videoEncoder = bundle.videoEncoder,
audioEncoder = bundle.audioEncoder,
cameraId = bundle.cameraId,
cameraSize = if (!bundle.cameraSizeUseCustom) bundle.cameraSize else bundle.cameraSizeCustom,
cameraAr = bundle.cameraAr,
cameraFps = bundle.cameraFps.toUShort(),
logLevel = LogLevel.valueOf(bundle.logLevel.uppercase()),
videoCodec = Codec.fromString(bundle.videoCodec),
audioCodec = Codec.fromString(bundle.audioCodec),
videoSource = VideoSource.fromString(bundle.videoSource),
audioSource = AudioSource.fromString(bundle.audioSource),
recordFormat = ClientOptions.RecordFormat.valueOf(bundle.recordFormat.uppercase()),
cameraFacing = CameraFacing.fromString(bundle.cameraFacing),
maxSize = bundle.maxSize.toUShort(),
videoBitRate = bundle.videoBitRate,
audioBitRate = bundle.audioBitRate,
maxFps = bundle.maxFps,
angle = bundle.angle,
captureOrientation = Orientation.fromInt(bundle.captureOrientation),
captureOrientationLock = OrientationLock.valueOf(
bundle.captureOrientationLock.uppercase()
),
displayOrientation = Orientation.fromInt(bundle.displayOrientation),
recordOrientation = Orientation.fromInt(bundle.recordOrientation),
displayImePolicy = DisplayImePolicy.valueOf(bundle.displayImePolicy.uppercase()),
displayId = bundle.displayId,
screenOffTimeout = Tick(bundle.screenOffTimeout),
showTouches = bundle.showTouches,
fullscreen = bundle.fullscreen,
control = bundle.control,
videoPlayback = bundle.videoPlayback,
audioPlayback = bundle.audioPlayback,
turnScreenOff = bundle.turnScreenOff,
stayAwake = bundle.stayAwake,
disableScreensaver = bundle.disableScreensaver,
powerOffOnClose = bundle.powerOffOnClose,
cleanUp = bundle.cleanup,
powerOn = bundle.powerOn,
video = bundle.video,
audio = bundle.audio,
requireAudio = bundle.requireAudio,
killAdbOnClose = bundle.killAdbOnClose,
cameraHighSpeed = bundle.cameraHighSpeed,
list = ListOptions.valueOf(bundle.list.uppercase()),
audioDup = bundle.audioDup,
newDisplay = bundle.newDisplay,
startApp = bundle.startApp,
vdDestroyContent = bundle.vdDestroyContent,
vdSystemDecorations = bundle.vdSystemDecorations
)
}

View File

@@ -0,0 +1,178 @@
package io.github.miuzarte.scrcpyforandroid.storage
import android.content.Context
import android.util.Log
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.datastore.core.DataStore
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlin.reflect.KProperty
import kotlin.time.Duration.Companion.milliseconds
private const val TAG = "Settings"
abstract class Settings(
context: Context,
private val name: String,
corruptionHandler: ReplaceFileCorruptionHandler<Preferences>? =
ReplaceFileCorruptionHandler {
Log.e(TAG, "Preferences corrupted, resetting.", it)
emptyPreferences()
}
) {
private val settingsScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
data class Pair<T>(
val key: Preferences.Key<T>,
val defaultValue: T,
) {
val name: String get() = key.name
}
protected fun <T> Preferences.read(pair: Pair<T>): T =
this[pair.key] ?: pair.defaultValue
protected interface BundleField<B> {
suspend fun persist(settings: Settings, current: B, new: B)
}
protected fun <B, T> bundleField(pair: Pair<T>, selector: (B) -> T): BundleField<B> =
object : BundleField<B> {
override suspend fun persist(settings: Settings, current: B, new: B) {
val currentValue = selector(current)
val newValue = selector(new)
if (currentValue != newValue) {
settings.setValue(pair, newValue)
}
}
}
/**
* 设置项委托类,自动提供 get/set/observe/asState/asMutableState 方法
*/
inner class SettingProperty<T>(
val pair: Pair<T>
) {
// 创建时注册自身
init {
registerProperty(pair.name, this)
}
operator fun getValue(thisRef: Any?, property: KProperty<*>): SettingProperty<T> = this
suspend fun isDefaultValue() = getValue(pair) == pair.defaultValue
suspend fun get(): T = getValue(pair)
suspend fun set(value: T) = setValue(pair, value)
fun observe(): Flow<T> = this@Settings.observe(pair)
@Composable
fun asState(): State<T> = this@Settings.asState(pair)
@Composable
fun asMutableState(): MutableState<T> = this@Settings.asMutableState(pair)
}
// 注册表, 用于遍历
private val propertyRegistry = mutableMapOf<String, SettingProperty<*>>()
private fun <T> registerProperty(name: String, property: SettingProperty<T>) {
propertyRegistry[name] = property
}
protected fun getAllProperties(): Map<String, SettingProperty<*>> = propertyRegistry.toMap()
// 为 Context 添加扩展委托属性,确保 DataStore 单例
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
name = this.name,
corruptionHandler = corruptionHandler,
scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
)
// 对外暴露的 DataStore 实例
protected val dataStore: DataStore<Preferences> = context.dataStore
protected fun <T> setting(pair: Pair<T>) = SettingProperty(pair)
protected fun <B> createBundleState(reader: (Preferences) -> B): StateFlow<B> =
dataStore.data
.map(reader)
.stateIn(
scope = settingsScope,
started = SharingStarted.Eagerly,
initialValue = runBlocking { loadBundle(reader) }
)
protected suspend fun <B> loadBundle(reader: (Preferences) -> B): B =
reader(dataStore.data.first())
protected suspend fun <B> saveBundle(
current: B,
new: B,
fields: Array<out BundleField<B>>,
) {
for (field in fields) {
field.persist(this, current, new)
}
}
protected suspend fun <T> getValue(pair: Pair<T>): T =
dataStore.data.first()[pair.key] ?: pair.defaultValue
protected suspend fun <T> setValue(pair: Pair<T>, value: T) =
dataStore.edit { preferences -> preferences[pair.key] = value }
protected fun <T> observe(pair: Pair<T>): Flow<T> =
dataStore.data.map { preferences -> preferences[pair.key] ?: pair.defaultValue }
@Composable
protected fun <T> asState(pair: Pair<T>): State<T> =
observe(pair).collectAsState(initial = pair.defaultValue)
@Composable
protected fun <T> asMutableState(pair: Pair<T>): MutableState<T> {
val scope = rememberCoroutineScope()
val state = asState(pair)
return rememberSaveable(state.value) {
object : MutableState<T> {
override var value: T
get() = state.value
set(newValue) {
scope.launch {
setValue(pair, newValue)
}
}
override fun component1(): T = value
override fun component2(): (T) -> Unit = { value = it }
}
}
}
companion object {
val BUNDLE_SAVE_DELAY = 100.milliseconds
}
}

View File

@@ -0,0 +1,17 @@
package io.github.miuzarte.scrcpyforandroid.storage
import android.content.Context
// settings singleton
object Storage {
private lateinit var appContext: Context
fun init(context: Context) {
appContext = context.applicationContext
}
val appSettings: AppSettings by lazy { AppSettings(appContext) }
val quickDevices: QuickDevices by lazy { QuickDevices(appContext) }
val scrcpyOptions: ScrcpyOptions by lazy { ScrcpyOptions(appContext) }
val adbClientData: AdbClientData by lazy { AdbClientData(appContext) }
}

View File

@@ -37,7 +37,7 @@ class ReorderableList(
private val showCheckbox: Boolean = false,
private val onCheckboxChange: ((String, Boolean) -> Unit)? = null,
) {
enum class Orientation { Column, Row }
enum class Orientation { Column, Row; }
data class Item(
val id: String,
@@ -78,44 +78,40 @@ class ReorderableList(
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Small)
) {
if (item.icon != null) {
Icon(
item.icon,
if (item.icon != null) Icon(
imageVector = item.icon,
contentDescription = item.title
)
Spacer(Modifier.padding(horizontal = 4.dp))
}
Column {
Text(
text = item.title,
color = MiuixTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
)
if (item.subtitle.isNotBlank()) {
Text(
if (item.subtitle.isNotBlank()) Text(
text = item.subtitle,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
fontSize = 13.sp,
)
}
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Small)
) {
if (showCheckbox) {
Checkbox(
if (showCheckbox) Checkbox(
state = if (item.checked) ToggleableState.On else ToggleableState.Off,
onClick = {
onCheckboxChange?.invoke(item.id, !item.checked)
},
enabled = item.checkboxEnabled
)
Spacer(Modifier.padding(horizontal = 4.dp))
}
IconButton(
onClick = {},
onClick = {
haptics.contextClick()
},
modifier = Modifier
.draggableHandle(
onDragStarted = {
@@ -193,7 +189,7 @@ class ReorderableList(
)
}
}
Spacer(Modifier.padding(UiSpacing.CardContent))
Spacer(Modifier.padding(UiSpacing.ContentVertical))
Text(
text = item.title,
color = MiuixTheme.colorScheme.onSurface,

View File

@@ -25,15 +25,17 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults
import io.github.miuzarte.scrcpyforandroid.constants.UiAndroidKeycodes
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
import kotlinx.coroutines.launch
import top.yukonga.miuix.kmp.basic.Button
import top.yukonga.miuix.kmp.basic.ButtonDefaults
import top.yukonga.miuix.kmp.basic.Icon
@@ -118,7 +120,7 @@ enum class VirtualButtonAction(
"截图",
Icons.Rounded.Screenshot,
UiAndroidKeycodes.SYSRQ
),
);
}
data class VirtualButtonItem(
@@ -133,7 +135,7 @@ object VirtualButtonActions {
fun parseStoredLayout(raw: String): List<VirtualButtonItem> {
if (raw.isBlank())
return parseStoredLayout(AppDefaults.VIRTUAL_BUTTONS_LAYOUT)
return parseStoredLayout(AppSettings.VIRTUAL_BUTTONS_LAYOUT.defaultValue)
return raw.split(',').mapNotNull { item ->
val parts = item.trim().split(':')
@@ -230,9 +232,10 @@ class VirtualButtonBar(
@Composable
fun Fullscreen(
onAction: (VirtualButtonAction) -> Unit,
onAction: suspend (VirtualButtonAction) -> Unit,
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
val haptics = rememberAppHaptics()
var showMorePopup by remember { mutableStateOf(false) }
@@ -248,8 +251,10 @@ class VirtualButtonBar(
if (action == VirtualButtonAction.MORE) {
showMorePopup = true
} else {
scope.launch {
onAction(action)
}
}
},
modifier = Modifier.fillMaxWidth(),
cornerRadius = 0.dp,
@@ -284,9 +289,10 @@ class VirtualButtonBar(
show: Boolean,
moreActions: List<VirtualButtonAction>,
onDismiss: () -> Unit,
onAction: (VirtualButtonAction) -> Unit,
onAction: suspend (VirtualButtonAction) -> Unit,
renderInRootScaffold: Boolean,
) {
val scope = rememberCoroutineScope()
val haptics = rememberAppHaptics()
val spinnerItems = remember(moreActions) {
moreActions.map { action ->
@@ -296,7 +302,7 @@ class VirtualButtonBar(
action.icon,
contentDescription = action.title,
modifier = Modifier
.padding(end = UiSpacing.CardContent),
.padding(end = UiSpacing.ContentVertical),
)
},
title = action.title,
@@ -323,7 +329,9 @@ class VirtualButtonBar(
dialogMode = false,
onSelectedIndexChange = { selectedIdx ->
haptics.confirm()
scope.launch {
onAction(moreActions[selectedIdx])
}
},
)
}

View File

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