├── .idea ├── .name ├── .gitignore ├── encodings.xml ├── codeStyles │ ├── codeStyleConfig.xml │ └── Project.xml ├── vcs.xml ├── compiler.xml ├── kotlinc.xml ├── modules.xml ├── ScrcpyHub.iml ├── misc.xml ├── saveactions_settings.xml ├── gradle.xml ├── jarRepositories.xml ├── libraries-with-intellij-classes.xml └── uiDesigner.xml ├── icon.ico ├── icon.png ├── icon.icns ├── docs ├── demo.gif └── .DS_Store ├── src ├── main │ ├── resources │ │ ├── icon.png │ │ ├── close.svg │ │ ├── device.svg │ │ ├── setting.svg │ │ ├── dots.svg │ │ └── tray.svg │ └── kotlin │ │ ├── model │ │ ├── entity │ │ │ ├── Theme.kt │ │ │ ├── Setting.kt │ │ │ ├── Message.kt │ │ │ └── Device.kt │ │ ├── command │ │ │ ├── KillCommand.kt │ │ │ ├── AdbCommand.kt │ │ │ ├── KillCommandCreator.kt │ │ │ ├── AdbCommandCreator.kt │ │ │ ├── ScrcpyCommand.kt │ │ │ └── ScrcpyCommandCreator.kt │ │ ├── usecase │ │ │ ├── FetchSettingUseCase.kt │ │ │ ├── UpdateSettingUseCase.kt │ │ │ ├── GetErrorMessageFlowUseCase.kt │ │ │ ├── GetNotifyMessageFlowUseCase.kt │ │ │ ├── GetScrcpyStatusUseCase.kt │ │ │ ├── FetchDevicesUseCase.kt │ │ │ ├── UpdateDeviceSetting.kt │ │ │ ├── RestartAdbServerUseCase.kt │ │ │ ├── StopScrcpyUseCase.kt │ │ │ ├── StopScrcpyRecordUseCase.kt │ │ │ ├── GetSystemDarkModeFlowUseCase.kt │ │ │ ├── CheckSetupStatusUseCase.kt │ │ │ ├── StartScrcpyUseCase.kt │ │ │ ├── SaveScreenshotUseCase.kt │ │ │ └── StartScrcpyRecordUseCase.kt │ │ ├── os │ │ │ ├── OSType.kt │ │ │ └── OSContext.kt │ │ ├── service │ │ │ └── AdbServerService.kt │ │ ├── repository │ │ │ ├── MessageRepository.kt │ │ │ ├── SettingRepository.kt │ │ │ ├── DeviceRepository.kt │ │ │ └── ProcessRepository.kt │ │ └── di │ │ │ └── Module.kt │ │ ├── view │ │ ├── resource │ │ │ ├── Images.kt │ │ │ ├── Colors.kt │ │ │ ├── Themes.kt │ │ │ └── Strings.kt │ │ ├── navigation │ │ │ └── Navigation.kt │ │ ├── pages │ │ │ ├── devices │ │ │ │ ├── DevicesPageState.kt │ │ │ │ ├── DevicesPageForMini.kt │ │ │ │ ├── DevicesPageStateHolder.kt │ │ │ │ └── DevicesPage.kt │ │ │ ├── license │ │ │ │ └── LicenseDialog.kt │ │ │ ├── device │ │ │ │ ├── DevicePageAction.kt │ │ │ │ ├── DevicePage.kt │ │ │ │ └── DevicePageState.kt │ │ │ ├── setting │ │ │ │ ├── SettingPage.kt │ │ │ │ └── SettingPageStateHolder.kt │ │ │ └── info │ │ │ │ └── InfoDialog.kt │ │ ├── StateHolder.kt │ │ ├── common │ │ │ └── ElapsedTimeCalculator.kt │ │ ├── parts │ │ │ ├── SmallIcon.kt │ │ │ ├── RadioButtons.kt │ │ │ ├── TitleAndRadioButtons.kt │ │ │ ├── TopPageMiniHeader.kt │ │ │ ├── TitleAndCheckButton.kt │ │ │ ├── SubPageHeader.kt │ │ │ ├── MenuButton.kt │ │ │ ├── TopPageHeader.kt │ │ │ ├── DropDownButton.kt │ │ │ ├── TextFieldAndError.kt │ │ │ └── Texts.kt │ │ ├── MainWindow.kt │ │ ├── templates │ │ │ └── HeaderAndContent.kt │ │ ├── components │ │ │ ├── DeviceList.kt │ │ │ ├── DevicePager.kt │ │ │ ├── ScrcpyButtons.kt │ │ │ ├── AppSetting.kt │ │ │ └── DeviceCard.kt │ │ ├── MainContentStateHolder.kt │ │ └── MainContent.kt │ │ └── ScrcpyHub.kt └── test │ └── kotlin │ ├── model │ └── command │ │ ├── KillCommandCreatorTest.kt │ │ ├── AdbCommandCreatorTest.kt │ │ └── ScrcpyCommandCreatorTest.kt │ └── view │ └── common │ └── ElapsedTimeCalculatorTest.kt ├── .editorconfig ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── Dangerfile ├── default.entitlements ├── .github ├── dependabot.yml └── workflows │ └── pull_request_check.yaml ├── .gitignore ├── settings.gradle.kts ├── README.md ├── gradlew.bat └── gradlew /.idea/.name: -------------------------------------------------------------------------------- 1 | ScrcpyHub -------------------------------------------------------------------------------- /icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kaleidot725/ScrcpyHub/HEAD/icon.ico -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kaleidot725/ScrcpyHub/HEAD/icon.png -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kaleidot725/ScrcpyHub/HEAD/icon.icns -------------------------------------------------------------------------------- /docs/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kaleidot725/ScrcpyHub/HEAD/docs/demo.gif -------------------------------------------------------------------------------- /docs/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kaleidot725/ScrcpyHub/HEAD/docs/.DS_Store -------------------------------------------------------------------------------- /src/main/resources/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kaleidot725/ScrcpyHub/HEAD/src/main/resources/icon.png -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [{*.kt, *.kts}] 4 | ktlint_function_naming_ignore_when_annotated_with=Composable 5 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kaleidot725/ScrcpyHub/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /.idea/encodings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/kotlinc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /src/main/kotlin/model/entity/Theme.kt: -------------------------------------------------------------------------------- 1 | package model.entity 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | enum class Theme { 7 | LIGHT, 8 | DARK, 9 | SYNC_WITH_OS, 10 | } 11 | -------------------------------------------------------------------------------- /Dangerfile: -------------------------------------------------------------------------------- 1 | # Warn when there is a big PR 2 | warn("Big PR") if git.lines_of_code > 500 3 | 4 | # ktlint 5 | checkstyle_format.base_path = Dir.pwd 6 | checkstyle_format.report 'build/reports/ktlint/ktlintMainSourceSetCheck/ktlintMainSourceSetCheck.xml' 7 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists -------------------------------------------------------------------------------- /src/main/kotlin/model/command/KillCommand.kt: -------------------------------------------------------------------------------- 1 | package model.command 2 | 3 | class KillCommand(private val factory: KillCommandCreator) { 4 | fun run(pid: Long): Process { 5 | val command = factory.create(pid) 6 | return ProcessBuilder(command).start() 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/main/kotlin/view/resource/Images.kt: -------------------------------------------------------------------------------- 1 | package view.resource 2 | 3 | object Images { 4 | const val TRAY = "tray.svg" 5 | const val DOTS = "dots.svg" 6 | const val DEVICE = "device.svg" 7 | const val SETTING = "setting.svg" 8 | const val CLOSE = "close.svg" 9 | } 10 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/main/resources/close.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/device.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/setting.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/dots.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/kotlin/model/usecase/FetchSettingUseCase.kt: -------------------------------------------------------------------------------- 1 | package model.usecase 2 | 3 | import model.entity.Setting 4 | import model.repository.SettingRepository 5 | 6 | class FetchSettingUseCase(private val settingRepository: SettingRepository) { 7 | suspend fun execute(): Setting { 8 | return settingRepository.get() 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/main/kotlin/model/os/OSType.kt: -------------------------------------------------------------------------------- 1 | package model.os 2 | 3 | enum class OSType { 4 | MAC_OS, 5 | LINUX, 6 | WINDOWS, 7 | } 8 | 9 | fun getOSType(): OSType { 10 | return when (System.getProperty("os.name")) { 11 | "Mac OS X" -> OSType.MAC_OS 12 | "Linux" -> OSType.LINUX 13 | else -> OSType.WINDOWS 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/main/kotlin/view/navigation/Navigation.kt: -------------------------------------------------------------------------------- 1 | package view.navigation 2 | 3 | import model.entity.Device 4 | 5 | sealed class Navigation(val name: String) { 6 | object DevicesPage : Navigation("Devices") 7 | 8 | data class DevicePage(val context: Device.Context) : Navigation("Device") 9 | 10 | object SettingPage : Navigation("Preferences") 11 | } 12 | -------------------------------------------------------------------------------- /.idea/ScrcpyHub.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/main/kotlin/model/command/AdbCommand.kt: -------------------------------------------------------------------------------- 1 | package model.command 2 | 3 | class AdbCommand(private val factory: AdbCommandCreator) { 4 | fun isInstalled(): Boolean { 5 | return try { 6 | ProcessBuilder(factory.createHelp()).start().destroy() 7 | true 8 | } catch (e: Exception) { 9 | false 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/main/kotlin/model/usecase/UpdateSettingUseCase.kt: -------------------------------------------------------------------------------- 1 | package model.usecase 2 | 3 | import model.entity.Setting 4 | import model.repository.SettingRepository 5 | 6 | class UpdateSettingUseCase( 7 | private val settingRepository: SettingRepository, 8 | ) { 9 | suspend fun execute(setting: Setting) { 10 | return settingRepository.update(setting) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/main/kotlin/model/usecase/GetErrorMessageFlowUseCase.kt: -------------------------------------------------------------------------------- 1 | package model.usecase 2 | 3 | import kotlinx.coroutines.flow.StateFlow 4 | import model.entity.Message 5 | import model.repository.MessageRepository 6 | 7 | class GetErrorMessageFlowUseCase( 8 | private val messageRepository: MessageRepository, 9 | ) { 10 | operator fun invoke(): StateFlow> { 11 | return messageRepository.errorMessages 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/main/kotlin/model/usecase/GetNotifyMessageFlowUseCase.kt: -------------------------------------------------------------------------------- 1 | package model.usecase 2 | 3 | import kotlinx.coroutines.flow.SharedFlow 4 | import model.entity.Message 5 | import model.repository.MessageRepository 6 | 7 | class GetNotifyMessageFlowUseCase( 8 | private val messageRepository: MessageRepository, 9 | ) { 10 | operator fun invoke(): SharedFlow { 11 | return messageRepository.notifyMessage 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/main/kotlin/model/usecase/GetScrcpyStatusUseCase.kt: -------------------------------------------------------------------------------- 1 | package model.usecase 2 | 3 | import model.entity.Device 4 | import model.repository.ProcessRepository 5 | import model.repository.ProcessStatus 6 | 7 | class GetScrcpyStatusUseCase( 8 | private val processRepository: ProcessRepository, 9 | ) { 10 | fun execute(context: Device.Context): ProcessStatus { 11 | return processRepository.getStatus(context.device.id) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/main/kotlin/model/usecase/FetchDevicesUseCase.kt: -------------------------------------------------------------------------------- 1 | package model.usecase 2 | 3 | import model.entity.Device 4 | import model.repository.DeviceRepository 5 | import model.service.AdbServerService 6 | 7 | class FetchDevicesUseCase(private val deviceRepository: DeviceRepository) { 8 | suspend fun execute(): List { 9 | if (!AdbServerService.isRunning) return emptyList() 10 | return deviceRepository.getAll() 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /default.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.cs.allow-jit 6 | 7 | com.apple.security.cs.allow-unsigned-executable-memory 8 | 9 | com.apple.security.cs.disable-library-validation 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/main/kotlin/model/usecase/UpdateDeviceSetting.kt: -------------------------------------------------------------------------------- 1 | package model.usecase 2 | 3 | import model.entity.Device 4 | import model.repository.DeviceRepository 5 | 6 | class UpdateDeviceSetting(private val deviceRepository: DeviceRepository) { 7 | suspend fun execute(newContext: Device.Context): Boolean { 8 | return try { 9 | deviceRepository.saveDeviceSetting(newContext) 10 | true 11 | } catch (e: Exception) { 12 | false 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 10 | -------------------------------------------------------------------------------- /src/main/kotlin/view/pages/devices/DevicesPageState.kt: -------------------------------------------------------------------------------- 1 | package view.pages.devices 2 | 3 | import model.entity.Device 4 | import model.repository.ProcessStatus 5 | 6 | sealed class DevicesPageState { 7 | object Loading : DevicesPageState() 8 | 9 | object DeviceIsEmpty : DevicesPageState() 10 | 11 | data class DeviceExist(val devices: List) : DevicesPageState() 12 | 13 | object Error : DevicesPageState() 14 | } 15 | 16 | data class DeviceStatus(val context: Device.Context, val processStatus: ProcessStatus) 17 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | 13 | -------------------------------------------------------------------------------- /.idea/saveactions_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 14 | 16 | -------------------------------------------------------------------------------- /src/main/kotlin/model/usecase/RestartAdbServerUseCase.kt: -------------------------------------------------------------------------------- 1 | package model.usecase 2 | 3 | import kotlinx.coroutines.Dispatchers 4 | import kotlinx.coroutines.withContext 5 | import model.repository.SettingRepository 6 | import model.service.AdbServerService 7 | 8 | class RestartAdbServerUseCase(private val settingRepository: SettingRepository) { 9 | suspend operator fun invoke(): Boolean { 10 | return withContext(Dispatchers.IO) { 11 | val setting = settingRepository.get() 12 | return@withContext AdbServerService.restartAdbServer(setting.adbLocation) 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/main/kotlin/model/entity/Setting.kt: -------------------------------------------------------------------------------- 1 | package model.entity 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class Setting( 8 | @SerialName("adbLocation") 9 | val adbLocation: String = "", 10 | @SerialName("theme") 11 | val theme: Theme = Theme.SYNC_WITH_OS, 12 | @SerialName("scrcpyLocation") 13 | val scrcpyLocation: String = "", 14 | @SerialName("screenshotDirectory") 15 | val screenRecordDirectory: String = "", 16 | @SerialName("screencaptureDirectory") 17 | val screenshotDirectory: String = "", 18 | ) 19 | -------------------------------------------------------------------------------- /src/main/kotlin/view/StateHolder.kt: -------------------------------------------------------------------------------- 1 | package view 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | import kotlinx.coroutines.Dispatchers 5 | import kotlinx.coroutines.SupervisorJob 6 | import kotlinx.coroutines.cancel 7 | import org.koin.core.component.KoinComponent 8 | 9 | abstract class StateHolder : KoinComponent { 10 | val coroutineScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main + Dispatchers.IO) 11 | 12 | open fun onStarted() {} 13 | 14 | open fun onRefresh() {} 15 | 16 | open fun onCleared() { 17 | coroutineScope.cancel("coroutine Canceled") 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main/kotlin/model/usecase/StopScrcpyUseCase.kt: -------------------------------------------------------------------------------- 1 | package model.usecase 2 | 3 | import model.entity.Device 4 | import model.entity.Message 5 | import model.repository.MessageRepository 6 | import model.repository.ProcessRepository 7 | 8 | class StopScrcpyUseCase( 9 | private val processRepository: ProcessRepository, 10 | private val messageRepository: MessageRepository, 11 | ) { 12 | suspend fun execute(context: Device.Context): Boolean { 13 | processRepository.delete(context.device.id) 14 | messageRepository.notify(Message.Notify.StopMirroring(context)) 15 | return true 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/main/kotlin/model/usecase/StopScrcpyRecordUseCase.kt: -------------------------------------------------------------------------------- 1 | package model.usecase 2 | 3 | import model.entity.Device 4 | import model.entity.Message 5 | import model.repository.MessageRepository 6 | import model.repository.ProcessRepository 7 | 8 | class StopScrcpyRecordUseCase( 9 | private val processRepository: ProcessRepository, 10 | private val messageRepository: MessageRepository, 11 | ) { 12 | suspend fun execute(context: Device.Context): Boolean { 13 | processRepository.delete(context.device.id) 14 | messageRepository.notify(Message.Notify.StopRecordingMovie(context)) 15 | return true 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | reated by https://www.toptal.com/developers/gitignore/api/gradle 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=gradle 3 | 4 | ### Gradle ### 5 | .gradle 6 | build/ 7 | 8 | # Ignore Gradle GUI config 9 | gradle-app.setting 10 | 11 | # Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) 12 | !gradle-wrapper.jar 13 | 14 | # Cache of project 15 | .gradletasknamecache 16 | 17 | # # Work around https://youtrack.jetbrains.com/issue/IDEA-116898 18 | # gradle/wrapper/gradle-wrapper.properties 19 | 20 | ### Gradle Patch ### 21 | **/build/ 22 | 23 | # End of https://www.toptal.com/developers/gitignore/api/gradle 24 | src/setting.json 25 | src/gradle.properties 26 | src/gradle.properties 27 | output 28 | -------------------------------------------------------------------------------- /src/test/kotlin/model/command/KillCommandCreatorTest.kt: -------------------------------------------------------------------------------- 1 | package model.command 2 | 3 | import io.kotest.core.spec.style.StringSpec 4 | import io.kotest.matchers.shouldBe 5 | 6 | class KillCommandCreatorTest : StringSpec( 7 | { 8 | "createForWindows" { 9 | val factory = KillCommandCreatorForWindows() 10 | factory.create(0) shouldBe listOf("taskkill", "/PID", "0") 11 | } 12 | "createForMacOs" { 13 | val factory = KillCommandCreatorForMacOS() 14 | factory.create(0) shouldBe listOf("kill", "-SIGINT", "0") 15 | } 16 | "createForLinux" { 17 | val factory = KillCommandCreatorForLinux() 18 | factory.create(0) shouldBe listOf("kill", "-SIGINT", "0") 19 | } 20 | }, 21 | ) 22 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 19 | 20 | -------------------------------------------------------------------------------- /src/main/kotlin/view/common/ElapsedTimeCalculator.kt: -------------------------------------------------------------------------------- 1 | package view.common 2 | 3 | import java.util.Date 4 | import java.util.concurrent.TimeUnit 5 | 6 | object ElapsedTimeCalculator { 7 | fun calc( 8 | startDate: Date, 9 | currentDate: Date, 10 | ): String { 11 | val totalMillis = currentDate.time - startDate.time 12 | val hour = TimeUnit.MILLISECONDS.toHours(totalMillis) 13 | val minute = TimeUnit.MILLISECONDS.toMinutes(totalMillis) % 60 14 | val second = TimeUnit.MILLISECONDS.toSeconds(totalMillis) % 60 % 60 15 | val hourString = hour.toString().padStart(2, '0') 16 | val minuteString = minute.toString().padStart(2, '0') 17 | val secondString = second.toString().padStart(2, '0') 18 | return "$hourString:$minuteString:$secondString" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/kotlin/model/usecase/GetSystemDarkModeFlowUseCase.kt: -------------------------------------------------------------------------------- 1 | package model.usecase 2 | 3 | import com.jthemedetecor.OsThemeDetector 4 | import kotlinx.coroutines.channels.awaitClose 5 | import kotlinx.coroutines.flow.Flow 6 | import kotlinx.coroutines.flow.callbackFlow 7 | 8 | class GetSystemDarkModeFlowUseCase() { 9 | operator fun invoke(): Flow { 10 | return callbackFlow { 11 | val detector: OsThemeDetector = OsThemeDetector.getDetector() 12 | this.trySend(detector.isDark) 13 | 14 | val listener: (Boolean) -> Unit = { isDark: Boolean -> 15 | this.trySend(isDark) 16 | } 17 | detector.registerListener(listener) 18 | 19 | awaitClose { 20 | detector.removeListener(listener) 21 | } 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.github/workflows/pull_request_check.yaml: -------------------------------------------------------------------------------- 1 | name: pull request check 2 | 3 | on: 4 | pull_request 5 | 6 | jobs: 7 | check: 8 | if: github.repository == 'kaleidot725/ScrcpyHub' 9 | name: Check pull request 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: ruby/setup-ruby@v1 14 | with: 15 | ruby-version: '3.0' 16 | bundler-cache: true 17 | - name: run ktlintCheck 18 | run: | 19 | ./gradlew --continue ktlintCheck 20 | continue-on-error: true 21 | - name: run test 22 | run: | 23 | ./gradlew test 24 | - name: run danger 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | run: | 28 | gem install danger danger-checkstyle_format danger-android_lint danger-junit 29 | danger 30 | -------------------------------------------------------------------------------- /src/main/kotlin/model/command/KillCommandCreator.kt: -------------------------------------------------------------------------------- 1 | package model.command 2 | 3 | interface KillCommandCreator { 4 | fun create(pid: Long): List 5 | } 6 | 7 | class KillCommandCreatorForMacOS : KillCommandCreator { 8 | override fun create(pid: Long): List { 9 | return buildList { 10 | add("kill") 11 | add("-SIGINT") 12 | add(pid.toString()) 13 | } 14 | } 15 | } 16 | 17 | class KillCommandCreatorForLinux : KillCommandCreator { 18 | override fun create(pid: Long): List { 19 | return buildList { 20 | add("kill") 21 | add("-SIGINT") 22 | add(pid.toString()) 23 | } 24 | } 25 | } 26 | 27 | class KillCommandCreatorForWindows : KillCommandCreator { 28 | override fun create(pid: Long): List { 29 | return buildList { 30 | add("taskkill") 31 | add("/PID") 32 | add(pid.toString()) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/main/kotlin/model/command/AdbCommandCreator.kt: -------------------------------------------------------------------------------- 1 | package model.command 2 | 3 | class AdbCommandCreator( 4 | val adbBinaryPath: String? = null, 5 | ) { 6 | fun create(): List { 7 | return buildList { 8 | add(resolveAdbBinaryPath()) 9 | } 10 | } 11 | 12 | fun createStartServer(): List { 13 | return buildList { 14 | add(resolveAdbBinaryPath()) 15 | add(START_SERVER) 16 | } 17 | } 18 | 19 | fun createHelp(): List { 20 | return buildList { 21 | add(resolveAdbBinaryPath()) 22 | add(HELP_OPTION) 23 | } 24 | } 25 | 26 | private fun resolveAdbBinaryPath(): String { 27 | return adbBinaryPath ?: DEFAULT_COMMAND_NAME 28 | } 29 | 30 | companion object { 31 | private const val DEFAULT_COMMAND_NAME = "adb" 32 | private const val HELP_OPTION = "--help" 33 | private const val START_SERVER = "start-server" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/main/resources/tray.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/main/kotlin/view/parts/SmallIcon.kt: -------------------------------------------------------------------------------- 1 | package view.parts 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.width 6 | import androidx.compose.material.MaterialTheme 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.graphics.ColorFilter 10 | import androidx.compose.ui.layout.ContentScale 11 | import androidx.compose.ui.res.painterResource 12 | import androidx.compose.ui.unit.dp 13 | 14 | @Composable 15 | fun SmallIcon( 16 | filePath: String, 17 | description: String, 18 | modifier: Modifier = Modifier, 19 | ) { 20 | Box(modifier = modifier) { 21 | Image( 22 | painter = painterResource(filePath), 23 | contentDescription = description, 24 | contentScale = ContentScale.Inside, 25 | colorFilter = ColorFilter.tint(MaterialTheme.colors.onSurface), 26 | modifier = Modifier.width(32.dp), 27 | ) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/main/kotlin/model/service/AdbServerService.kt: -------------------------------------------------------------------------------- 1 | package model.service 2 | 3 | import com.malinskiy.adam.interactor.StartAdbInteractor 4 | import com.malinskiy.adam.interactor.StopAdbInteractor 5 | import java.io.File 6 | 7 | object AdbServerService { 8 | private var latestBinaryPath: String? = null 9 | 10 | val isRunning get() = latestBinaryPath != null 11 | 12 | suspend fun restartAdbServer(path: String): Boolean { 13 | if (latestBinaryPath == path) return true 14 | stopAdbServer() 15 | return startAdbServer(path) 16 | } 17 | 18 | private suspend fun startAdbServer(path: String): Boolean { 19 | val result = StartAdbInteractor().execute(adbBinary = File(path)) 20 | if (result) latestBinaryPath = path 21 | return result 22 | } 23 | 24 | private suspend fun stopAdbServer(): Boolean { 25 | val path = latestBinaryPath ?: return true 26 | val result = StopAdbInteractor().execute(File(path)) 27 | if (result) latestBinaryPath = null 28 | return result 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/main/kotlin/model/entity/Message.kt: -------------------------------------------------------------------------------- 1 | package model.entity 2 | 3 | import java.util.UUID 4 | 5 | sealed class Message(val uuid: UUID = UUID.randomUUID()) { 6 | sealed class Notify : Message() { 7 | data class SuccessToSaveScreenshot(val context: Device.Context, val fileName: String) : Notify() 8 | 9 | data class FailedToSaveScreenshot(val context: Device.Context) : Notify() 10 | 11 | data class StartRecordingMovie(val context: Device.Context) : Notify() 12 | 13 | data class StopRecordingMovie(val context: Device.Context) : Notify() 14 | 15 | data class FailedRecordingMovie(val context: Device.Context) : Notify() 16 | 17 | data class StartMirroring(val context: Device.Context) : Notify() 18 | 19 | data class StopMirroring(val context: Device.Context) : Notify() 20 | 21 | data class FailedMirroring(val context: Device.Context) : Notify() 22 | } 23 | 24 | sealed class Error : Message() { 25 | object NotFoundAdbBinary : Error() 26 | 27 | object NotFoundScrcpyBinary : Error() 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/main/kotlin/model/os/OSContext.kt: -------------------------------------------------------------------------------- 1 | package model.os 2 | 3 | interface OSContext { 4 | val type: OSType 5 | val settingPath: String 6 | val desktopPath: String 7 | } 8 | 9 | class OSContextForMac : OSContext { 10 | override val type: OSType = OSType.MAC_OS 11 | override val settingPath: String = 12 | System.getProperty("user.home") + "/Library/Application Support/ScrcpyHub/" 13 | override val desktopPath: String = 14 | System.getProperty("user.home") + "/Desktop/" 15 | } 16 | 17 | class OSContextForLinux : OSContext { 18 | override val type: OSType = OSType.LINUX 19 | override val settingPath: String = System.getProperty("user.home") + "/.config/ScrcpyHub" 20 | override val desktopPath: String = System.getProperty("user.home") + "/Desktop/" 21 | } 22 | 23 | class OSContextForWindows : OSContext { 24 | override val type: OSType = OSType.WINDOWS 25 | override val settingPath: String = System.getProperty("user.home") + "/AppData/Local/ScrcpyHub/" 26 | override val desktopPath: String = System.getProperty("user.home") + "/Desktop/" 27 | } 28 | -------------------------------------------------------------------------------- /src/main/kotlin/view/pages/license/LicenseDialog.kt: -------------------------------------------------------------------------------- 1 | package view.pages.license 2 | 3 | import androidx.compose.foundation.layout.fillMaxSize 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.ui.Modifier 6 | import androidx.compose.ui.res.painterResource 7 | import androidx.compose.ui.res.useResource 8 | import androidx.compose.ui.window.Dialog 9 | import com.mikepenz.aboutlibraries.ui.compose.LibrariesContainer 10 | import view.resource.Images 11 | import view.resource.MainTheme 12 | import view.resource.Strings.LICENSE_TITLE 13 | 14 | private const val FILE_NAME = "aboutlibraries.json" 15 | 16 | @Composable 17 | fun LicenseDialog( 18 | isDark: Boolean, 19 | onClose: () -> Unit, 20 | ) { 21 | MainTheme(isDarkMode = isDark) { 22 | Dialog( 23 | onCloseRequest = onClose, 24 | title = LICENSE_TITLE, 25 | icon = painterResource(Images.TRAY), 26 | ) { 27 | LibrariesContainer( 28 | useResource(FILE_NAME) { it.bufferedReader().readText() }, 29 | Modifier.fillMaxSize(), 30 | ) 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/main/kotlin/view/resource/Colors.kt: -------------------------------------------------------------------------------- 1 | package view.resource 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | object Colors { 6 | val WINDOW_BORDER = Color(0x60CCCCCC) 7 | 8 | val LIGHT_PRIMARY = Color(0xFF006399) 9 | val LIGHT_ON_PRIMARY = Color(0xFFffffff) 10 | val LIGHT_SECONDARY = Color(0xFF51606f) 11 | val LIGHT_ON_SECONDARY = Color(0xFFffffff) 12 | val LIGHT_ERROR = Color(0xFFba1b1b) 13 | val LIGHT_ON_ERROR = Color(0xFFffffff) 14 | val LIGHT_BACKGROUND = Color(0xFFE0E0E0) 15 | val LIGHT_ON_BACKGROUND = Color(0xFF1a1c1e) 16 | val LIGHT_SURFACE = Color(0xFFEEEEEE) 17 | val LIGHT_ON_SURFACE = Color(0xFF1a1c1e) 18 | 19 | val DARK_PRIMARY = Color(0xFF90ccff) 20 | val DARK_ON_PRIMARY = Color(0xFF003352) 21 | val DARK_SECONDARY = Color(0xFFb8c8da) 22 | val DARK_ON_SECONDARY = Color(0xFF233240) 23 | val DARK_ERROR = Color(0xFFffb4a9) 24 | val DARK_ON_ERROR = Color(0xFF680003) 25 | val DARK_BACKGROUND = Color(0xFF1a1c1e) 26 | val DARK_ON_BACKGROUND = Color(0xFFe2e2e5) 27 | val DARK_SURFACE = Color(0xFF1a1c1e) 28 | val DARK_ON_SURFACE = Color(0xFFe2e2e5) 29 | } 30 | -------------------------------------------------------------------------------- /src/main/kotlin/view/parts/RadioButtons.kt: -------------------------------------------------------------------------------- 1 | package view.parts 2 | 3 | import androidx.compose.desktop.ui.tooling.preview.Preview 4 | import androidx.compose.foundation.layout.Row 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.foundation.layout.size 7 | import androidx.compose.material.RadioButton 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.unit.dp 11 | 12 | @Composable 13 | fun RadioButtons( 14 | selectedItem: String, 15 | items: List, 16 | onSelect: (String) -> Unit, 17 | modifier: Modifier = Modifier, 18 | ) { 19 | Row(modifier = modifier) { 20 | items.forEach { item -> 21 | RadioButton( 22 | selected = (item == selectedItem), 23 | onClick = { onSelect(item) }, 24 | modifier = Modifier.size(16.dp), 25 | ) 26 | Texts.Caption(item, modifier = Modifier.padding(horizontal = 16.dp)) 27 | } 28 | } 29 | } 30 | 31 | @Preview 32 | @Composable 33 | private fun RadioButtons_Preview() { 34 | RadioButtons("one", listOf("one", "two", "three"), {}) 35 | } 36 | -------------------------------------------------------------------------------- /src/main/kotlin/view/pages/device/DevicePageAction.kt: -------------------------------------------------------------------------------- 1 | package view.pages.device 2 | 3 | import model.entity.Device 4 | 5 | interface DevicePageAction { 6 | fun updateName(name: String) 7 | 8 | fun updateMaxSize(maxSize: String) 9 | 10 | fun updateMaxFrameRate(maxFrameRate: String) 11 | 12 | fun updateBitrate(bitrate: String) 13 | 14 | fun updateBuffering(buffering: String) 15 | 16 | fun updateEnableStayAwake(enable: Boolean) 17 | 18 | fun updateEnableShowTouches(enable: Boolean) 19 | 20 | fun updateEnablePowerOffOnClose(enable: Boolean) 21 | 22 | fun updateDisablePowerOnOnStart(disable: Boolean) 23 | 24 | fun updateNoAudio(noAudio: Boolean) 25 | 26 | fun updateAudioBuffering(buffering: String) 27 | 28 | fun updateAudioBitrate(bitrate: String) 29 | 30 | fun updateLockOrientation(captureOrientation: Device.Context.CaptureOrientation) 31 | 32 | fun updateBorderless(enabled: Boolean) 33 | 34 | fun updateAlwaysOnTop(enabled: Boolean) 35 | 36 | fun updateFullscreen(enabled: Boolean) 37 | 38 | fun updateRotation(orientation: Device.Context.Orientation) 39 | 40 | fun updateEnableHidKeyboard(enabled: Boolean) 41 | 42 | fun updateEnableHidMouse(enabled: Boolean) 43 | 44 | fun save() 45 | } 46 | -------------------------------------------------------------------------------- /src/main/kotlin/model/usecase/CheckSetupStatusUseCase.kt: -------------------------------------------------------------------------------- 1 | package model.usecase 2 | 3 | import model.command.AdbCommand 4 | import model.command.AdbCommandCreator 5 | import model.command.ScrcpyCommand 6 | import model.command.ScrcpyCommandCreator 7 | import model.entity.Message 8 | import model.repository.MessageRepository 9 | import model.repository.SettingRepository 10 | 11 | class CheckSetupStatusUseCase( 12 | private val messageRepository: MessageRepository, 13 | private val settingRepository: SettingRepository, 14 | ) { 15 | suspend operator fun invoke() { 16 | val setting = settingRepository.get() 17 | 18 | val adbCommand = AdbCommand(AdbCommandCreator(setting.adbLocation)) 19 | if (!adbCommand.isInstalled()) { 20 | messageRepository.pushError(Message.Error.NotFoundAdbBinary) 21 | } else { 22 | messageRepository.popError(Message.Error.NotFoundAdbBinary) 23 | } 24 | 25 | val scrcpyCommand = ScrcpyCommand(ScrcpyCommandCreator(setting.scrcpyLocation)) 26 | if (!scrcpyCommand.isInstalled()) { 27 | messageRepository.pushError(Message.Error.NotFoundScrcpyBinary) 28 | } else { 29 | messageRepository.popError(Message.Error.NotFoundScrcpyBinary) 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/main/kotlin/model/usecase/StartScrcpyUseCase.kt: -------------------------------------------------------------------------------- 1 | package model.usecase 2 | 3 | import model.entity.Device 4 | import model.entity.Message 5 | import model.repository.MessageRepository 6 | import model.repository.ProcessRepository 7 | import model.repository.ProcessStatus 8 | import model.repository.SettingRepository 9 | 10 | class StartScrcpyUseCase( 11 | private val settingRepository: SettingRepository, 12 | private val processRepository: ProcessRepository, 13 | private val messageRepository: MessageRepository, 14 | ) { 15 | suspend fun execute( 16 | context: Device.Context, 17 | onDestroy: suspend () -> Unit, 18 | ): Boolean { 19 | val lastState = processRepository.getStatus(context.device.id) 20 | if (lastState != ProcessStatus.Idle) { 21 | return false 22 | } 23 | 24 | return try { 25 | processRepository.addMirroringProcess(context, settingRepository.get().scrcpyLocation) { 26 | onDestroy.invoke() 27 | } 28 | messageRepository.notify(Message.Notify.StartMirroring(context)) 29 | true 30 | } catch (e: Exception) { 31 | messageRepository.notify(Message.Notify.FailedMirroring(context)) 32 | false 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/main/kotlin/view/pages/device/DevicePage.kt: -------------------------------------------------------------------------------- 1 | package view.pages.device 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.DisposableEffect 5 | import androidx.compose.runtime.collectAsState 6 | import androidx.compose.runtime.getValue 7 | import androidx.compose.ui.window.WindowScope 8 | import view.parts.SubPageHeader 9 | import view.templates.MainLayout 10 | 11 | @Composable 12 | fun DevicePage( 13 | windowScope: WindowScope, 14 | stateHolder: DevicePageStateHolder, 15 | onNavigateDevices: (() -> Unit)? = null, 16 | ) { 17 | val state by stateHolder.state.collectAsState() 18 | 19 | DisposableEffect(stateHolder) { 20 | stateHolder.onStarted() 21 | onDispose { 22 | stateHolder.onCleared() 23 | } 24 | } 25 | 26 | MainLayout(header = { 27 | SubPageHeader( 28 | windowScope = windowScope, 29 | title = state.titleName, 30 | onCancel = { onNavigateDevices?.invoke() }, 31 | onSave = { 32 | stateHolder.viewAction.save() 33 | onNavigateDevices?.invoke() 34 | }, 35 | savable = state.savable, 36 | ) 37 | }, content = { 38 | DeviceSetting(state, stateHolder.viewAction) 39 | }) 40 | } 41 | -------------------------------------------------------------------------------- /src/main/kotlin/view/MainWindow.kt: -------------------------------------------------------------------------------- 1 | package view 2 | 3 | import androidx.compose.foundation.BorderStroke 4 | import androidx.compose.foundation.shape.RoundedCornerShape 5 | import androidx.compose.material.Card 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.res.painterResource 8 | import androidx.compose.ui.unit.dp 9 | import androidx.compose.ui.window.FrameWindowScope 10 | import androidx.compose.ui.window.Window 11 | import androidx.compose.ui.window.WindowState 12 | import view.resource.Colors 13 | import view.resource.Images 14 | import view.resource.Strings 15 | 16 | @Composable 17 | fun MainWindow( 18 | onCloseRequest: () -> Unit, 19 | state: WindowState, 20 | alwaysOnTop: Boolean, 21 | content: @Composable FrameWindowScope.() -> Unit, 22 | ) { 23 | Window( 24 | onCloseRequest = onCloseRequest, 25 | state = state, 26 | title = Strings.APP_NAME, 27 | resizable = false, 28 | undecorated = true, 29 | transparent = true, 30 | alwaysOnTop = alwaysOnTop, 31 | icon = painterResource(Images.TRAY), 32 | ) { 33 | Card( 34 | shape = RoundedCornerShape(8.dp), 35 | border = BorderStroke(1.dp, Colors.WINDOW_BORDER), 36 | ) { content.invoke(this) } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/main/kotlin/view/parts/TitleAndRadioButtons.kt: -------------------------------------------------------------------------------- 1 | package view.parts 2 | 3 | import androidx.compose.desktop.ui.tooling.preview.Preview 4 | import androidx.compose.foundation.layout.Arrangement 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.foundation.layout.wrapContentSize 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.unit.dp 11 | 12 | @Composable 13 | fun TitleAndRadioButtons( 14 | title: String, 15 | selectedItem: String, 16 | items: List, 17 | onSelect: (String) -> Unit, 18 | modifier: Modifier = Modifier, 19 | ) { 20 | Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) { 21 | Texts.Subtitle1(title) 22 | RadioButtons( 23 | selectedItem = selectedItem, 24 | items = items, 25 | onSelect = { onSelect(it) }, 26 | modifier = Modifier.padding(8.dp), 27 | ) 28 | } 29 | } 30 | 31 | @Preview 32 | @Composable 33 | private fun TitleAndRadioButtons_Preview() { 34 | TitleAndRadioButtons( 35 | "CUSTOM SUBTITLE1", 36 | "one", 37 | listOf("one", "two", "three"), 38 | {}, 39 | modifier = Modifier.wrapContentSize(), 40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /src/main/kotlin/model/command/ScrcpyCommand.kt: -------------------------------------------------------------------------------- 1 | package model.command 2 | 3 | import model.entity.Device 4 | import java.io.File 5 | 6 | class ScrcpyCommand(private val factory: ScrcpyCommandCreator) { 7 | fun run(context: Device.Context): Process { 8 | val command = factory.create(context) 9 | return ProcessBuilder(command).apply { 10 | setupCommandPath(factory.scrcpyBinaryPath) 11 | }.start() 12 | } 13 | 14 | fun record( 15 | context: Device.Context, 16 | fileName: String, 17 | ): Process { 18 | val command = factory.createRecord(context, fileName) 19 | return ProcessBuilder(command).apply { 20 | setupCommandPath(factory.scrcpyBinaryPath) 21 | }.start() 22 | } 23 | 24 | fun isInstalled(): Boolean { 25 | return try { 26 | ProcessBuilder(factory.createHelp()).start().destroy() 27 | true 28 | } catch (e: Exception) { 29 | false 30 | } 31 | } 32 | 33 | private fun ProcessBuilder.setupCommandPath(binaryFile: String?) { 34 | environment()["PATH"] = 35 | if (binaryFile != null) { 36 | File(binaryFile).parent + File.pathSeparator + System.getenv("PATH") 37 | } else { 38 | System.getenv("PATH") 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/main/kotlin/model/repository/MessageRepository.kt: -------------------------------------------------------------------------------- 1 | package model.repository 2 | 3 | import kotlinx.coroutines.flow.MutableSharedFlow 4 | import kotlinx.coroutines.flow.MutableStateFlow 5 | import kotlinx.coroutines.flow.SharedFlow 6 | import kotlinx.coroutines.flow.StateFlow 7 | import model.entity.Message 8 | 9 | class MessageRepository { 10 | private val _notifyMessage: MutableSharedFlow = MutableSharedFlow(replay = 0) 11 | val notifyMessage: SharedFlow = _notifyMessage 12 | 13 | private var latestErrorMessages: Set = setOf() 14 | private val _errorMessages: MutableStateFlow> = MutableStateFlow(emptySet()) 15 | val errorMessages: StateFlow> = _errorMessages 16 | 17 | suspend fun notify(message: Message.Notify) { 18 | _notifyMessage.emit(message) 19 | } 20 | 21 | suspend fun pushError(message: Message.Error) { 22 | val newErrorMessages = latestErrorMessages.toMutableSet() 23 | newErrorMessages.add(message) 24 | _errorMessages.emit(newErrorMessages) 25 | latestErrorMessages = newErrorMessages 26 | } 27 | 28 | suspend fun popError(message: Message.Error) { 29 | val newErrorMessages = latestErrorMessages.toMutableSet() 30 | newErrorMessages.remove(message) 31 | _errorMessages.emit(newErrorMessages) 32 | latestErrorMessages = newErrorMessages 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/kotlin/view/templates/HeaderAndContent.kt: -------------------------------------------------------------------------------- 1 | package view.templates 2 | 3 | import androidx.compose.foundation.BorderStroke 4 | import androidx.compose.foundation.background 5 | import androidx.compose.foundation.border 6 | import androidx.compose.foundation.layout.Box 7 | import androidx.compose.foundation.layout.Column 8 | import androidx.compose.foundation.layout.fillMaxSize 9 | import androidx.compose.foundation.shape.RoundedCornerShape 10 | import androidx.compose.material.MaterialTheme 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.ui.Alignment 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.draw.clip 15 | import androidx.compose.ui.unit.dp 16 | import view.resource.Colors 17 | 18 | @Composable 19 | fun MainLayout( 20 | header: @Composable () -> Unit, 21 | content: @Composable () -> Unit, 22 | snackBar: @Composable () -> Unit = {}, 23 | ) { 24 | Box( 25 | modifier = 26 | Modifier 27 | .fillMaxSize() 28 | .background(MaterialTheme.colors.background) 29 | .clip(RoundedCornerShape(8.dp)) 30 | .border(BorderStroke(1.dp, Colors.WINDOW_BORDER)), 31 | ) { 32 | Column(modifier = Modifier.fillMaxSize()) { 33 | header() 34 | Box(Modifier.weight(1.0f)) { content() } 35 | } 36 | 37 | Box(Modifier.align(Alignment.BottomCenter)) { 38 | snackBar() 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/test/kotlin/model/command/AdbCommandCreatorTest.kt: -------------------------------------------------------------------------------- 1 | package model.command 2 | 3 | import io.kotest.core.spec.style.StringSpec 4 | import io.kotest.matchers.shouldBe 5 | import java.io.File.separator as fileSeparator 6 | 7 | class AdbCommandCreatorTest : StringSpec( 8 | { 9 | "create" { 10 | val factory = AdbCommandCreator(adbBinaryPath = "test${fileSeparator}adb") 11 | factory.create() shouldBe listOf("test${fileSeparator}adb") 12 | } 13 | "create_when_no_path_specified" { 14 | val factory = AdbCommandCreator() 15 | factory.create() shouldBe listOf("adb") 16 | } 17 | "create_help" { 18 | val factory = AdbCommandCreator(adbBinaryPath = "test${fileSeparator}adb") 19 | factory.createHelp() shouldBe listOf("test${fileSeparator}adb", "--help") 20 | } 21 | "create_help_when_no_path_specified" { 22 | val factory = AdbCommandCreator() 23 | factory.createHelp() shouldBe listOf("adb", "--help") 24 | } 25 | "create_start_server" { 26 | val factory = AdbCommandCreator(adbBinaryPath = "test${fileSeparator}adb") 27 | factory.createStartServer() shouldBe listOf("test${fileSeparator}adb", "start-server") 28 | } 29 | "create_start_server_when_no_path_specified" { 30 | val factory = AdbCommandCreator() 31 | factory.createStartServer() shouldBe listOf("adb", "start-server") 32 | } 33 | }, 34 | ) 35 | -------------------------------------------------------------------------------- /src/main/kotlin/view/parts/TopPageMiniHeader.kt: -------------------------------------------------------------------------------- 1 | package view.parts 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.fillMaxWidth 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.foundation.layout.wrapContentHeight 8 | import androidx.compose.foundation.window.WindowDraggableArea 9 | import androidx.compose.material.Text 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.Alignment 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.graphics.Color 14 | import androidx.compose.ui.text.style.TextAlign 15 | import androidx.compose.ui.text.style.TextOverflow 16 | import androidx.compose.ui.unit.dp 17 | import androidx.compose.ui.window.WindowScope 18 | 19 | @Composable 20 | fun TopPageMiniHeader( 21 | windowScope: WindowScope, 22 | title: String, 23 | ) { 24 | windowScope.WindowDraggableArea { 25 | Box( 26 | modifier = 27 | Modifier 28 | .fillMaxWidth() 29 | .wrapContentHeight() 30 | .background(Color(red = 51, blue = 51, green = 51)) 31 | .padding(8.dp), 32 | ) { 33 | Text( 34 | text = title, 35 | maxLines = 1, 36 | overflow = TextOverflow.Ellipsis, 37 | color = Color.White, 38 | textAlign = TextAlign.Center, 39 | modifier = Modifier.wrapContentHeight().align(Alignment.Center), 40 | ) 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/main/kotlin/view/pages/device/DevicePageState.kt: -------------------------------------------------------------------------------- 1 | package view.pages.device 2 | 3 | import model.entity.Device 4 | 5 | data class DevicePageState( 6 | val titleName: String = "", 7 | val editName: String = "", 8 | val enableStayAwake: Boolean = false, 9 | val enableShowTouches: Boolean = false, 10 | val enablePowerOffOnClose: Boolean = false, 11 | val disablePowerOnOnStart: Boolean = false, 12 | val maxSize: String = "", 13 | val maxSizeError: String = "", 14 | val maxFrameRate: String = "", 15 | val maxFrameRateError: String = "", 16 | val bitrate: String = "", 17 | val bitrateError: String = "", 18 | val buffering: String = "", 19 | val bufferingError: String = "", 20 | val noAudio: Boolean = false, 21 | val audioBitrate: String = "", 22 | val audioBitrateError: String = "", 23 | val audioBuffering: String = "", 24 | val audioBufferingError: String = "", 25 | val captureOrientation: Device.Context.CaptureOrientation = Device.Context.CaptureOrientation.NONE, 26 | val enableBorderless: Boolean = false, 27 | val enableAlwaysOnTop: Boolean = false, 28 | val enableFullScreen: Boolean = false, 29 | val orientation: Device.Context.Orientation = Device.Context.Orientation.NONE, 30 | val enableHidKeyboard: Boolean = false, 31 | val enableHidMouse: Boolean = false, 32 | val savable: Boolean = false, 33 | ) { 34 | val captureOrientations: List = Device.Context.CaptureOrientation.values().toList() 35 | val orientations: List = Device.Context.Orientation.values().toList() 36 | } 37 | -------------------------------------------------------------------------------- /src/main/kotlin/model/entity/Device.kt: -------------------------------------------------------------------------------- 1 | package model.entity 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class Device(val id: String = "") { 7 | @Serializable 8 | data class Context( 9 | val device: Device, 10 | val customName: String? = null, 11 | val enableStayAwake: Boolean = false, 12 | val enableShowTouches: Boolean = false, 13 | val enablePowerOffOnClose: Boolean = false, 14 | val disablePowerOnOnStart: Boolean = false, 15 | val maxSize: Int? = null, 16 | val maxFrameRate: Int? = null, 17 | val lockOrientation: Int? = null, 18 | val bitrate: Int? = null, 19 | val buffering: Int? = null, 20 | val noAudio: Boolean = false, 21 | val audioBitrate: Int? = null, 22 | val audioBuffering: Int? = null, 23 | val enableBorderless: Boolean = false, 24 | val enableAlwaysOnTop: Boolean = false, 25 | val enableFullScreen: Boolean = false, 26 | val enableHidKeyboard: Boolean = false, 27 | val enableHidMouse: Boolean = false, 28 | val rotation: Int? = null, 29 | ) { 30 | val displayName get() = if (customName.isNullOrEmpty()) device.id else customName 31 | 32 | enum class CaptureOrientation(val value: Int?) { 33 | NONE(null), 34 | NATURAL(0), 35 | COUNTER_CLOCK_WISE_90(270), 36 | CLOCK_WISE_180(180), 37 | CLOCK_WISE_90(90), 38 | } 39 | 40 | enum class Orientation(val value: Int?) { 41 | NONE(null), 42 | COUNTER_CLOCK_WISE_90(270), 43 | CLOCK_WISE_180(180), 44 | CLOCK_WISE_90(90), 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/main/kotlin/model/repository/SettingRepository.kt: -------------------------------------------------------------------------------- 1 | package model.repository 2 | 3 | import kotlinx.coroutines.Dispatchers 4 | import kotlinx.coroutines.withContext 5 | import kotlinx.serialization.decodeFromString 6 | import kotlinx.serialization.encodeToString 7 | import kotlinx.serialization.json.Json 8 | import model.entity.Setting 9 | import model.os.OSContext 10 | import java.io.File 11 | 12 | class SettingRepository(private val osContext: OSContext) { 13 | suspend fun get(): Setting { 14 | return withContext(Dispatchers.IO) { 15 | load() 16 | } 17 | } 18 | 19 | suspend fun update(setting: Setting) { 20 | withContext(Dispatchers.IO) { 21 | createDir() 22 | write(setting) 23 | } 24 | } 25 | 26 | private fun write(setting: Setting) { 27 | try { 28 | File(osContext.settingPath + SETTING_FILE_NAME).outputStream().apply { 29 | this.write(Json.encodeToString(setting).toByteArray()) 30 | this.close() 31 | } 32 | } catch (e: Exception) { 33 | return 34 | } 35 | } 36 | 37 | private fun load(): Setting { 38 | return try { 39 | val content = File(osContext.settingPath + SETTING_FILE_NAME).readText() 40 | Json.decodeFromString(string = content) 41 | } catch (e: Exception) { 42 | Setting() 43 | } 44 | } 45 | 46 | private fun createDir() { 47 | try { 48 | val file = File(osContext.settingPath) 49 | if (!file.exists()) file.mkdir() 50 | } catch (e: Exception) { 51 | return 52 | } 53 | } 54 | 55 | companion object { 56 | private const val SETTING_FILE_NAME = "setting.json" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /.idea/jarRepositories.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 14 | 15 | 19 | 20 | 24 | 25 | 29 | 30 | 34 | 35 | 39 | 40 | -------------------------------------------------------------------------------- /src/test/kotlin/view/common/ElapsedTimeCalculatorTest.kt: -------------------------------------------------------------------------------- 1 | package view.common 2 | 3 | import io.kotest.core.spec.style.StringSpec 4 | import io.kotest.matchers.shouldBe 5 | import java.util.Date 6 | 7 | class ElapsedTimeCalculatorTest : StringSpec({ 8 | "seconds" { 9 | // 2022/12/20/12:00:00 10 | val startTime = 1671505200000 11 | 12 | // 2022/12/20/12:00:01 13 | val elapsedSecond1 = 1671505201000 14 | ElapsedTimeCalculator.calc(Date(startTime), Date(elapsedSecond1)) shouldBe "00:00:01" 15 | 16 | // 2022/12/20/12:00:59 17 | val elapsedSecond59 = 1671505259000 18 | ElapsedTimeCalculator.calc(Date(startTime), Date(elapsedSecond59)) shouldBe "00:00:59" 19 | } 20 | "minites" { 21 | // 2022/12/20/12:00:00 22 | val startTime = 1671505200000 23 | 24 | // 2022/12/20/12:01:00 25 | val elapsedMinute1 = 1671505260000 26 | ElapsedTimeCalculator.calc(Date(startTime), Date(elapsedMinute1)) shouldBe "00:01:00" 27 | 28 | // 2022/12/20/12:59:00 29 | val elapsedMinute59 = 1671508740000 30 | ElapsedTimeCalculator.calc(Date(startTime), Date(elapsedMinute59)) shouldBe "00:59:00" 31 | } 32 | "hours" { 33 | // 2022/12/20/12:00:00 34 | val startTime = 1671505200000 35 | 36 | // 2022/12/20/13:00:00 37 | val elapsedHour1 = 1671508800000 38 | ElapsedTimeCalculator.calc(Date(startTime), Date(elapsedHour1)) shouldBe "01:00:00" 39 | 40 | // 2022/12/21/12:00:00 41 | val elapsedHour24 = 1671591600000 42 | ElapsedTimeCalculator.calc(Date(startTime), Date(elapsedHour24)) shouldBe "24:00:00" 43 | 44 | // 2022/12/22/12:00:00 45 | val elapsedHour48 = 1671678000000 46 | ElapsedTimeCalculator.calc(Date(startTime), Date(elapsedHour48)) shouldBe "48:00:00" 47 | } 48 | }) 49 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | gradlePluginPortal() 4 | mavenCentral() 5 | maven { url = uri("https://maven.pkg.jetbrains.space/public/p/compose/dev") } 6 | maven("https://maven.hq.hydraulic.software") 7 | } 8 | plugins { 9 | kotlin("jvm").version("2.0.20") 10 | kotlin("plugin.serialization").version("2.0.20") 11 | id("org.jetbrains.kotlin.plugin.compose").version("2.0.20") 12 | id("org.jetbrains.compose").version("1.6.11") 13 | id("org.jlleitschuh.gradle.ktlint").version("12.1.1") 14 | id("com.mikepenz.aboutlibraries.plugin").version("11.2.3") 15 | } 16 | } 17 | 18 | dependencyResolutionManagement { 19 | repositories { 20 | google() 21 | mavenCentral() 22 | maven("https://jitpack.io") 23 | maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") 24 | } 25 | versionCatalogs { 26 | create("libs") { 27 | library("kotlin-coroutines", "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1") 28 | library("napier", "io.github.aakira:napier:2.7.1") 29 | library("turtle", "com.lordcodes.turtle:turtle:0.10.0") 30 | library("koin", "io.insert-koin:koin-core:3.5.6") 31 | library("kotlin-serialization", "org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.2") 32 | library("adam", "com.malinskiy.adam:adam:0.5.8") 33 | library("jSystemThemeDetector", "com.github.Dansoftowner:jSystemThemeDetector:3.6") 34 | library("junit", "org.junit.jupiter:junit-jupiter:5.9.0") 35 | library("mockk", "io.mockk:mockk:1.13.2") 36 | library("kotest", "io.kotest:kotest-runner-junit5:5.5.4") 37 | library("aboutlibraries-core", "com.mikepenz:aboutlibraries-core:11.2.3") 38 | library("aboutlibraries-compose", "com.mikepenz:aboutlibraries-compose:11.2.3") 39 | } 40 | } 41 | } 42 | 43 | rootProject.name = "ScrcpyHub" 44 | -------------------------------------------------------------------------------- /src/main/kotlin/view/parts/TitleAndCheckButton.kt: -------------------------------------------------------------------------------- 1 | package view.parts 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.Row 7 | import androidx.compose.foundation.layout.Spacer 8 | import androidx.compose.foundation.layout.fillMaxWidth 9 | import androidx.compose.foundation.layout.height 10 | import androidx.compose.foundation.layout.padding 11 | import androidx.compose.foundation.layout.wrapContentHeight 12 | import androidx.compose.foundation.shape.RoundedCornerShape 13 | import androidx.compose.material.Checkbox 14 | import androidx.compose.material.MaterialTheme 15 | import androidx.compose.material.Text 16 | import androidx.compose.runtime.Composable 17 | import androidx.compose.ui.Alignment 18 | import androidx.compose.ui.Modifier 19 | import androidx.compose.ui.unit.dp 20 | import androidx.compose.ui.unit.sp 21 | 22 | @Composable 23 | fun TitleAndCheckButton( 24 | title: String, 25 | subTitle: String, 26 | value: Boolean, 27 | onSelect: (Boolean) -> Unit, 28 | modifier: Modifier = Modifier, 29 | ) { 30 | Box(modifier) { 31 | Row( 32 | Modifier 33 | .fillMaxWidth() 34 | .wrapContentHeight() 35 | .background(MaterialTheme.colors.onSurface.copy(alpha = 0.12f), shape = RoundedCornerShape(4.dp)) 36 | .padding(vertical = 8.dp, horizontal = 16.dp), 37 | ) { 38 | Column( 39 | modifier = Modifier.weight(1.0f).align(Alignment.CenterVertically), 40 | ) { 41 | Text( 42 | text = title, 43 | fontSize = 16.sp, 44 | ) 45 | 46 | Spacer(Modifier.height(2.dp)) 47 | 48 | Text( 49 | text = subTitle, 50 | fontSize = 12.sp, 51 | ) 52 | } 53 | 54 | Checkbox( 55 | checked = value, 56 | onCheckedChange = { onSelect(it) }, 57 | modifier = Modifier.align(Alignment.CenterVertically), 58 | ) 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/main/kotlin/model/usecase/SaveScreenshotUseCase.kt: -------------------------------------------------------------------------------- 1 | package model.usecase 2 | 3 | import model.entity.Device 4 | import model.entity.Message 5 | import model.repository.DeviceRepository 6 | import model.repository.MessageRepository 7 | import model.repository.SettingRepository 8 | import java.io.File 9 | import java.time.ZoneId 10 | import java.time.ZonedDateTime 11 | import java.time.format.DateTimeFormatter 12 | 13 | class SaveScreenshotUseCase( 14 | private val deviceRepository: DeviceRepository, 15 | private val messageRepository: MessageRepository, 16 | private val settingRepository: SettingRepository, 17 | ) { 18 | suspend fun execute(context: Device.Context): Boolean { 19 | val directory = createScreenshotDirectory() 20 | if (!File(directory).exists()) { 21 | messageRepository.notify(Message.Notify.FailedToSaveScreenshot(context)) 22 | return false 23 | } 24 | 25 | val filePath = createScreenshotPath(directory, context) 26 | return deviceRepository.saveScreenshot(context.device, filePath).apply { 27 | val message = 28 | if (this) { 29 | Message.Notify.SuccessToSaveScreenshot(context, filePath) 30 | } else { 31 | Message.Notify.FailedToSaveScreenshot(context) 32 | } 33 | messageRepository.notify(message) 34 | } 35 | } 36 | 37 | private suspend fun createScreenshotDirectory(): String { 38 | val screenshotDirectory = settingRepository.get().screenshotDirectory 39 | return if (screenshotDirectory.isEmpty()) { 40 | "${System.getProperty("user.home")}/Desktop/" 41 | } else { 42 | if (screenshotDirectory.endsWith("/")) { 43 | screenshotDirectory 44 | } else { 45 | "$screenshotDirectory/" 46 | } 47 | } 48 | } 49 | 50 | private fun createScreenshotPath( 51 | directory: String, 52 | context: Device.Context, 53 | ): String { 54 | val date = 55 | ZonedDateTime 56 | .now(ZoneId.systemDefault()) 57 | .format(DateTimeFormatter.ofPattern("yyyy_MM_dd_HH_mm_ss")) 58 | return "$directory${context.displayName}-$date.png" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/main/kotlin/view/resource/Themes.kt: -------------------------------------------------------------------------------- 1 | package view.resource 2 | 3 | import androidx.compose.material.MaterialTheme 4 | import androidx.compose.material.darkColors 5 | import androidx.compose.material.lightColors 6 | import androidx.compose.runtime.Composable 7 | import view.resource.Colors.DARK_BACKGROUND 8 | import view.resource.Colors.DARK_ERROR 9 | import view.resource.Colors.DARK_ON_BACKGROUND 10 | import view.resource.Colors.DARK_ON_ERROR 11 | import view.resource.Colors.DARK_ON_PRIMARY 12 | import view.resource.Colors.DARK_ON_SECONDARY 13 | import view.resource.Colors.DARK_ON_SURFACE 14 | import view.resource.Colors.DARK_PRIMARY 15 | import view.resource.Colors.DARK_SECONDARY 16 | import view.resource.Colors.DARK_SURFACE 17 | import view.resource.Colors.LIGHT_BACKGROUND 18 | import view.resource.Colors.LIGHT_ERROR 19 | import view.resource.Colors.LIGHT_ON_BACKGROUND 20 | import view.resource.Colors.LIGHT_ON_ERROR 21 | import view.resource.Colors.LIGHT_ON_PRIMARY 22 | import view.resource.Colors.LIGHT_ON_SECONDARY 23 | import view.resource.Colors.LIGHT_ON_SURFACE 24 | import view.resource.Colors.LIGHT_PRIMARY 25 | import view.resource.Colors.LIGHT_SECONDARY 26 | import view.resource.Colors.LIGHT_SURFACE 27 | 28 | @Composable 29 | fun MainTheme( 30 | isDarkMode: Boolean, 31 | content: @Composable () -> Unit, 32 | ) { 33 | MaterialTheme( 34 | content = content, 35 | colors = if (isDarkMode) darkThemeColors else lightThemeColors, 36 | ) 37 | } 38 | 39 | private val lightThemeColors = 40 | lightColors( 41 | primary = LIGHT_PRIMARY, 42 | onPrimary = LIGHT_ON_PRIMARY, 43 | secondary = LIGHT_SECONDARY, 44 | onSecondary = LIGHT_ON_SECONDARY, 45 | error = LIGHT_ERROR, 46 | onError = LIGHT_ON_ERROR, 47 | background = LIGHT_BACKGROUND, 48 | onBackground = LIGHT_ON_BACKGROUND, 49 | surface = LIGHT_SURFACE, 50 | onSurface = LIGHT_ON_SURFACE, 51 | ) 52 | 53 | private val darkThemeColors = 54 | darkColors( 55 | primary = DARK_PRIMARY, 56 | onPrimary = DARK_ON_PRIMARY, 57 | secondary = DARK_SECONDARY, 58 | onSecondary = DARK_ON_SECONDARY, 59 | error = DARK_ERROR, 60 | onError = DARK_ON_ERROR, 61 | background = DARK_BACKGROUND, 62 | onBackground = DARK_ON_BACKGROUND, 63 | surface = DARK_SURFACE, 64 | onSurface = DARK_ON_SURFACE, 65 | ) 66 | -------------------------------------------------------------------------------- /src/main/kotlin/view/parts/SubPageHeader.kt: -------------------------------------------------------------------------------- 1 | package view.parts 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.Row 5 | import androidx.compose.foundation.layout.fillMaxWidth 6 | import androidx.compose.foundation.layout.height 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.foundation.layout.wrapContentHeight 9 | import androidx.compose.foundation.window.WindowDraggableArea 10 | import androidx.compose.material.Text 11 | import androidx.compose.material.TextButton 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.ui.Alignment 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.graphics.Color 16 | import androidx.compose.ui.text.font.FontWeight 17 | import androidx.compose.ui.text.style.TextAlign 18 | import androidx.compose.ui.text.style.TextOverflow 19 | import androidx.compose.ui.unit.dp 20 | import androidx.compose.ui.window.WindowScope 21 | import view.resource.Strings 22 | 23 | @Composable 24 | fun SubPageHeader( 25 | windowScope: WindowScope, 26 | title: String, 27 | onCancel: () -> Unit, 28 | onSave: () -> Unit, 29 | savable: Boolean, 30 | ) { 31 | windowScope.WindowDraggableArea { 32 | Row( 33 | modifier = 34 | Modifier 35 | .fillMaxWidth() 36 | .height(48.dp) 37 | .background(Color(red = 51, blue = 51, green = 51)) 38 | .padding(8.dp), 39 | ) { 40 | TextButton(onClick = onCancel) { 41 | Text( 42 | text = Strings.CANCEL, 43 | color = Color.White, 44 | ) 45 | } 46 | 47 | Text( 48 | text = title, 49 | textAlign = TextAlign.Center, 50 | modifier = 51 | Modifier 52 | .wrapContentHeight() 53 | .weight(1.0f) 54 | .align(Alignment.CenterVertically), 55 | maxLines = 1, 56 | fontWeight = FontWeight.Bold, 57 | overflow = TextOverflow.Ellipsis, 58 | color = Color.White, 59 | ) 60 | 61 | TextButton(onClick = onSave, enabled = savable) { 62 | Text( 63 | text = Strings.SAVE, 64 | color = Color.White, 65 | ) 66 | } 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/main/kotlin/view/parts/MenuButton.kt: -------------------------------------------------------------------------------- 1 | package view.parts 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.fillMaxSize 7 | import androidx.compose.material.Surface 8 | import androidx.compose.material.Text 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Alignment 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.graphics.Color 13 | import androidx.compose.ui.text.TextStyle 14 | import androidx.compose.ui.text.style.TextOverflow 15 | 16 | enum class MenuButtonStatus { 17 | ACTIVE, 18 | ENABLE, 19 | DISABLE, 20 | } 21 | 22 | data class MenuButtonColors( 23 | val active: Color, 24 | val enable: Color, 25 | val disable: Color, 26 | val textColor: Color, 27 | val textColorOnDisable: Color, 28 | ) 29 | 30 | @Composable 31 | fun MenuButton( 32 | text: String, 33 | style: TextStyle, 34 | status: MenuButtonStatus, 35 | colors: MenuButtonColors, 36 | onIdleClick: () -> Unit, 37 | onActiveClick: () -> Unit, 38 | modifier: Modifier = Modifier, 39 | ) { 40 | Surface(modifier) { 41 | Box( 42 | Modifier 43 | .fillMaxSize() 44 | .clickable( 45 | onClick = { 46 | if (status == MenuButtonStatus.ACTIVE) onActiveClick() 47 | if (status == MenuButtonStatus.ENABLE) onIdleClick() 48 | }, 49 | enabled = (status == MenuButtonStatus.ENABLE) || (status == MenuButtonStatus.ACTIVE), 50 | ) 51 | .background( 52 | when (status) { 53 | MenuButtonStatus.ACTIVE -> colors.active 54 | MenuButtonStatus.ENABLE -> colors.enable 55 | MenuButtonStatus.DISABLE -> colors.disable 56 | }, 57 | ), 58 | ) { 59 | Text( 60 | text = text, 61 | maxLines = 1, 62 | style = style, 63 | color = 64 | when (status) { 65 | MenuButtonStatus.DISABLE -> colors.textColorOnDisable 66 | else -> colors.textColor 67 | }, 68 | overflow = TextOverflow.Ellipsis, 69 | modifier = Modifier.align(Alignment.Center), 70 | ) 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/main/kotlin/view/components/DeviceList.kt: -------------------------------------------------------------------------------- 1 | package view.components 2 | 3 | import androidx.compose.foundation.ExperimentalFoundationApi 4 | import androidx.compose.foundation.VerticalScrollbar 5 | import androidx.compose.foundation.layout.Arrangement 6 | import androidx.compose.foundation.layout.Box 7 | import androidx.compose.foundation.layout.fillMaxHeight 8 | import androidx.compose.foundation.layout.fillMaxSize 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.foundation.lazy.LazyColumn 11 | import androidx.compose.foundation.lazy.items 12 | import androidx.compose.foundation.lazy.rememberLazyListState 13 | import androidx.compose.foundation.rememberScrollbarAdapter 14 | import androidx.compose.runtime.Composable 15 | import androidx.compose.ui.Alignment 16 | import androidx.compose.ui.Modifier 17 | import androidx.compose.ui.unit.dp 18 | import model.entity.Device 19 | import view.pages.devices.DeviceStatus 20 | 21 | @OptIn(ExperimentalFoundationApi::class) 22 | @Composable 23 | fun DeviceList( 24 | deviceStatusList: List, 25 | startScrcpy: ((Device.Context) -> Unit), 26 | stopScrcpy: ((Device.Context) -> Unit), 27 | goToDetail: ((Device.Context) -> Unit), 28 | takeScreenshot: ((Device.Context) -> Unit), 29 | startRecording: ((Device.Context) -> Unit), 30 | stopRecording: ((Device.Context) -> Unit), 31 | ) { 32 | Box(modifier = Modifier.fillMaxSize()) { 33 | val lazyColumnState = rememberLazyListState() 34 | LazyColumn( 35 | verticalArrangement = Arrangement.spacedBy(8.dp), 36 | state = lazyColumnState, 37 | modifier = 38 | Modifier 39 | .fillMaxSize() 40 | .padding(horizontal = 12.dp) 41 | .padding(vertical = 12.dp), 42 | ) { 43 | items(items = deviceStatusList, key = { it.context.device.id }) { deviceStatus -> 44 | DeviceCard( 45 | deviceStatus, 46 | true, 47 | startScrcpy, 48 | stopScrcpy, 49 | goToDetail, 50 | takeScreenshot, 51 | startRecording, 52 | stopRecording, 53 | Modifier.animateItemPlacement(), 54 | ) 55 | } 56 | } 57 | 58 | VerticalScrollbar( 59 | adapter = rememberScrollbarAdapter(lazyColumnState), 60 | modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(), 61 | ) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/main/kotlin/view/pages/setting/SettingPage.kt: -------------------------------------------------------------------------------- 1 | package view.pages.setting 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.DisposableEffect 5 | import androidx.compose.runtime.collectAsState 6 | import androidx.compose.runtime.getValue 7 | import androidx.compose.ui.window.WindowScope 8 | import model.entity.Theme 9 | import view.components.AppSetting 10 | import view.parts.SubPageHeader 11 | import view.resource.Strings 12 | import view.templates.MainLayout 13 | 14 | @Composable 15 | fun SettingPage( 16 | windowScope: WindowScope, 17 | stateHolder: SettingPageStateHolder, 18 | onNavigateDevices: (() -> Unit)? = null, 19 | onSaved: (() -> Unit)? = null, 20 | ) { 21 | val theme: Theme by stateHolder.theme.collectAsState() 22 | val themes: List by stateHolder.themes.collectAsState() 23 | val adbLocation: String by stateHolder.adbLocation.collectAsState() 24 | val scrcpyLocation: String by stateHolder.scrcpyLocation.collectAsState() 25 | val screenshotDirectory: String by stateHolder.screenshotDirectory.collectAsState() 26 | val screenRecordDirectory: String by stateHolder.screenRecordDirectory.collectAsState() 27 | 28 | DisposableEffect(stateHolder) { 29 | stateHolder.onStarted() 30 | onDispose { 31 | stateHolder.onCleared() 32 | } 33 | } 34 | 35 | MainLayout( 36 | header = { 37 | SubPageHeader( 38 | windowScope = windowScope, 39 | title = Strings.SETTING_PAGE_TITLE, 40 | onCancel = { onNavigateDevices?.invoke() }, 41 | onSave = { 42 | stateHolder.save { onSaved?.invoke() } 43 | onNavigateDevices?.invoke() 44 | }, 45 | savable = true, 46 | ) 47 | }, 48 | content = { 49 | AppSetting( 50 | theme = theme, 51 | themes = themes, 52 | onUpdateTheme = stateHolder::updateTheme, 53 | adbLocation = adbLocation, 54 | onUpdateAdbLocation = stateHolder::updateAdbLocation, 55 | scrcpyLocation = scrcpyLocation, 56 | onUpdateScrcpyLocation = stateHolder::updateScrcpyLocation, 57 | screenRecordDirectory = screenRecordDirectory, 58 | onUpdateScreenshotDirectory = stateHolder::updateScreenshotDirectory, 59 | screenshotDirectory = screenshotDirectory, 60 | onUpdateScreenRecordDirectory = stateHolder::updateScreenRecordDirectory, 61 | ) 62 | }, 63 | ) 64 | } 65 | -------------------------------------------------------------------------------- /src/main/kotlin/model/usecase/StartScrcpyRecordUseCase.kt: -------------------------------------------------------------------------------- 1 | package model.usecase 2 | 3 | import model.entity.Device 4 | import model.entity.Message 5 | import model.repository.MessageRepository 6 | import model.repository.ProcessRepository 7 | import model.repository.ProcessStatus 8 | import model.repository.SettingRepository 9 | import java.io.File 10 | import java.time.ZoneId 11 | import java.time.ZonedDateTime 12 | import java.time.format.DateTimeFormatter 13 | 14 | class StartScrcpyRecordUseCase( 15 | private val settingRepository: SettingRepository, 16 | private val processRepository: ProcessRepository, 17 | private val messageRepository: MessageRepository, 18 | ) { 19 | suspend fun execute( 20 | context: Device.Context, 21 | onDestroy: suspend () -> Unit, 22 | ): Boolean { 23 | val lastState = processRepository.getStatus(context.device.id) 24 | if (lastState != ProcessStatus.Idle) { 25 | return false 26 | } 27 | 28 | try { 29 | val directory = createRecordDirectory() 30 | if (!File(directory).exists()) { 31 | messageRepository.notify(Message.Notify.FailedRecordingMovie(context)) 32 | return false 33 | } 34 | 35 | val fileName = createRecordPath(directory, context) 36 | val scrcpyLocation = settingRepository.get().scrcpyLocation 37 | processRepository.addRecordingProcess(context, fileName, scrcpyLocation) { 38 | onDestroy.invoke() 39 | } 40 | messageRepository.notify(Message.Notify.StartRecordingMovie(context)) 41 | return true 42 | } catch (e: Exception) { 43 | return false 44 | } 45 | } 46 | 47 | private suspend fun createRecordDirectory(): String { 48 | val screenRecordDirectory = settingRepository.get().screenRecordDirectory 49 | return if (screenRecordDirectory.isEmpty()) { 50 | "${System.getProperty("user.home")}/Desktop/" 51 | } else { 52 | if (screenRecordDirectory.endsWith("/")) { 53 | screenRecordDirectory 54 | } else { 55 | "$screenRecordDirectory/" 56 | } 57 | } 58 | } 59 | 60 | private fun createRecordPath( 61 | directory: String, 62 | context: Device.Context, 63 | ): String { 64 | val date = 65 | ZonedDateTime 66 | .now(ZoneId.systemDefault()) 67 | .format(DateTimeFormatter.ofPattern("yyyy_MM_dd_HH_mm_ss")) 68 | return "$directory${context.displayName}-$date.mp4" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/main/kotlin/view/parts/TopPageHeader.kt: -------------------------------------------------------------------------------- 1 | package view.parts 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.foundation.background 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.foundation.layout.height 8 | import androidx.compose.foundation.layout.padding 9 | import androidx.compose.foundation.layout.wrapContentHeight 10 | import androidx.compose.foundation.window.WindowDraggableArea 11 | import androidx.compose.material.IconButton 12 | import androidx.compose.material.Text 13 | import androidx.compose.material.icons.Icons 14 | import androidx.compose.material.icons.filled.Settings 15 | import androidx.compose.runtime.Composable 16 | import androidx.compose.ui.Alignment 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.graphics.Color 19 | import androidx.compose.ui.graphics.ColorFilter 20 | import androidx.compose.ui.layout.ContentScale 21 | import androidx.compose.ui.text.font.FontWeight 22 | import androidx.compose.ui.text.style.TextAlign 23 | import androidx.compose.ui.text.style.TextOverflow 24 | import androidx.compose.ui.unit.dp 25 | import androidx.compose.ui.window.WindowScope 26 | 27 | @Composable 28 | fun TopPageHeader( 29 | windowScope: WindowScope, 30 | title: String, 31 | onClickOption: () -> Unit, 32 | ) { 33 | windowScope.WindowDraggableArea { 34 | Box( 35 | modifier = 36 | Modifier 37 | .fillMaxWidth() 38 | .height(48.dp) 39 | .background(Color(red = 51, blue = 51, green = 51)) 40 | .padding(8.dp), 41 | ) { 42 | Text( 43 | text = title, 44 | textAlign = TextAlign.Center, 45 | modifier = 46 | Modifier 47 | .wrapContentHeight() 48 | .align(Alignment.Center), 49 | fontWeight = FontWeight.Bold, 50 | maxLines = 1, 51 | overflow = TextOverflow.Ellipsis, 52 | color = Color.White, 53 | ) 54 | 55 | IconButton( 56 | onClick = onClickOption, 57 | modifier = Modifier.padding(top = 2.dp).align(Alignment.CenterEnd), 58 | ) { 59 | Image( 60 | imageVector = Icons.Default.Settings, 61 | contentDescription = "", 62 | contentScale = ContentScale.FillHeight, 63 | colorFilter = ColorFilter.tint(Color.White), 64 | ) 65 | } 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/main/kotlin/view/MainContentStateHolder.kt: -------------------------------------------------------------------------------- 1 | package view 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | import kotlinx.coroutines.flow.MutableStateFlow 5 | import kotlinx.coroutines.flow.SharingStarted 6 | import kotlinx.coroutines.flow.StateFlow 7 | import kotlinx.coroutines.flow.combine 8 | import kotlinx.coroutines.flow.stateIn 9 | import kotlinx.coroutines.launch 10 | import model.entity.Setting 11 | import model.entity.Theme 12 | import model.usecase.CheckSetupStatusUseCase 13 | import model.usecase.FetchSettingUseCase 14 | import model.usecase.GetSystemDarkModeFlowUseCase 15 | import model.usecase.RestartAdbServerUseCase 16 | import view.navigation.Navigation 17 | 18 | class MainContentStateHolder( 19 | private val fetchSettingUseCase: FetchSettingUseCase, 20 | private val checkSetupStatusUseCase: CheckSetupStatusUseCase, 21 | private val getSystemDarkModeFlowUseCase: GetSystemDarkModeFlowUseCase, 22 | private val restartAdbServerUseCase: RestartAdbServerUseCase, 23 | ) : StateHolder() { 24 | private val _navState: MutableStateFlow = MutableStateFlow(Navigation.DevicesPage) 25 | val navState: StateFlow = _navState 26 | 27 | private val _setting: MutableStateFlow = MutableStateFlow(Setting()) 28 | val setting: StateFlow = _setting 29 | 30 | private val systemDarkMode: StateFlow = 31 | getSystemDarkModeFlowUseCase() 32 | .stateIn(coroutineScope, SharingStarted.WhileSubscribed(), null) 33 | val isDarkMode: Flow = 34 | _setting.combine(systemDarkMode) { setting, systemDarkMode -> 35 | systemDarkMode ?: return@combine null 36 | when (setting.theme) { 37 | Theme.LIGHT -> false 38 | Theme.DARK -> true 39 | Theme.SYNC_WITH_OS -> systemDarkMode 40 | } 41 | } 42 | 43 | override fun onStarted() { 44 | updateSetting() 45 | checkSetupStatus() 46 | restartAdbServer() 47 | } 48 | 49 | override fun onRefresh() { 50 | updateSetting() 51 | checkSetupStatus() 52 | restartAdbServer() 53 | } 54 | 55 | fun selectPage(page: Navigation) { 56 | _navState.value = page 57 | } 58 | 59 | private fun restartAdbServer() { 60 | coroutineScope.launch { 61 | restartAdbServerUseCase() 62 | } 63 | } 64 | 65 | private fun updateSetting() { 66 | coroutineScope.launch { 67 | _setting.value = fetchSettingUseCase.execute() 68 | } 69 | } 70 | 71 | private fun checkSetupStatus() { 72 | coroutineScope.launch { 73 | checkSetupStatusUseCase() 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![scrcpyhub](https://user-images.githubusercontent.com/23740796/234660382-7406256a-a544-484e-84d0-3df31dc39649.png) 2 | 3 | # ScrcpyHub 4 | 5 | ScrcpyHub is a GUI application to mirror android screens.(Scrcpy GUI) 6 | ScrcpyHub uses [scrcpy](https://github.com/Genymobile/scrcpy). [scrcpy](https://github.com/Genymobile/scrcpy) is a 7 | mirroring android command tool. 8 | 9 | https://user-images.githubusercontent.com/23740796/227752167-4258bc4e-c7c8-459a-90b7-bcef3167ea9f.mp4 10 | 11 | # ✨ Feature 12 | 13 | - Control mirroring (Start / Stop). 🪞 14 | - Mirror multi android device screen and audio. 📱 15 | - Save screenshots. 📸 16 | - Record movies. 🎥 17 | - Support for Windows 10/11, Linux and macOS. 🖥️ 18 | - Support light theme and dark themes. 🖼️ 19 | - Support normal and mini window mode. 📟 20 | - Support tray menu. 📥 21 | - Display and hide window. 22 | - Enable always on top. 23 | 24 | | Normal Window | Mini Window | 25 | | ------------- | ----------- | 26 | | ![image](https://user-images.githubusercontent.com/23740796/236205730-9711b47c-bd98-40a1-a26a-3dbbace0a295.png) | ![image](https://user-images.githubusercontent.com/23740796/236205790-7e761242-7829-4a28-9ae7-6981f1bf3845.png) | 27 | 28 | # ⬇️ Install 29 | 30 | > [!CAUTION] 31 | > ScrcpyHub v2.3.0 supports scrpy v3.0+. If you use ScrcpyHub v2.3.0, please update scrcpy to v3.0+. 32 | 33 | Install adb and scrcpy, ScrcpyHub. 34 | 35 | ## Windows 10/11 36 | 37 | 1. Download [here](https://github.com/Genymobile/scrcpy#windows) and install scrcpy (v3.0+) somewhere 38 | 2. Download [here](https://github.com/kaleidot725/ScrcpyHub/releases/tag/v2.1.0) and launch ScrcpyHub. 39 | 3. Open ScrcpyHub Preferences. 40 | 4. Input adb and scrcpy location, save settings. 41 | 42 | https://github.com/kaleidot725/ScrcpyHub/assets/23740796/51e555f0-12ab-41f0-822f-ceacdfec884c 43 | 44 | #### WARNING: Not recommend Chocolately. 45 | 46 | ScrcpyHub doesn't work mirroring start and stop if you install scrcpy through Chocolately. 47 | 48 | ## Linux(Ubuntu) 49 | 50 | 1. Download [here](https://github.com/Genymobile/scrcpy#windows) and install scrcpy (v3.0+) somewhere. 51 | 2. Build this project and install ScrcpHub. 52 | 3. Launch ScrcpyHub, open Preferences. 53 | 4. Input adb and scrcpy location, save settings. 54 | 55 | ## macOS 56 | 57 | 1. Install android-platform-tools and scrcpyv(v3.0+). 58 | 59 | ``` 60 | brew install android-platform-tools 61 | brew install scrcpy 62 | ``` 63 | 64 | 2. Download [here](https://github.com/kaleidot725/ScrcpyHub/releases/tag/v2.1.0) and launch ScrcpyHub. 65 | 3. Open ScrcpyHub Preferences. 66 | 4. Input adb and scrcpy location, save settings. 67 | 68 | # 🎫 Licence 69 | 70 | The GNU General Public License v3.0 (GPLv3) 71 | -------------------------------------------------------------------------------- /src/main/kotlin/view/parts/DropDownButton.kt: -------------------------------------------------------------------------------- 1 | package view.parts 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.Column 7 | import androidx.compose.foundation.layout.Spacer 8 | import androidx.compose.foundation.layout.fillMaxWidth 9 | import androidx.compose.foundation.layout.height 10 | import androidx.compose.foundation.layout.padding 11 | import androidx.compose.foundation.layout.wrapContentHeight 12 | import androidx.compose.foundation.shape.RoundedCornerShape 13 | import androidx.compose.material.ContentAlpha 14 | import androidx.compose.material.DropdownMenu 15 | import androidx.compose.material.DropdownMenuItem 16 | import androidx.compose.material.MaterialTheme 17 | import androidx.compose.material.Text 18 | import androidx.compose.runtime.Composable 19 | import androidx.compose.runtime.getValue 20 | import androidx.compose.runtime.mutableStateOf 21 | import androidx.compose.runtime.remember 22 | import androidx.compose.runtime.setValue 23 | import androidx.compose.ui.Modifier 24 | import androidx.compose.ui.unit.dp 25 | import androidx.compose.ui.unit.sp 26 | 27 | @Composable 28 | fun DropDownSelector( 29 | label: String, 30 | selectedItem: String, 31 | items: List, 32 | onSelect: (String) -> Unit, 33 | modifier: Modifier = Modifier, 34 | ) { 35 | var expanded by remember { mutableStateOf(false) } 36 | Box(modifier) { 37 | Column( 38 | Modifier 39 | .fillMaxWidth() 40 | .wrapContentHeight() 41 | .background(MaterialTheme.colors.onSurface.copy(alpha = 0.12f), shape = RoundedCornerShape(4.dp)) 42 | .clickable(onClick = { expanded = true }) 43 | .padding(vertical = 8.dp, horizontal = 16.dp), 44 | ) { 45 | Text( 46 | text = label, 47 | fontSize = 14.sp, 48 | color = if (expanded) MaterialTheme.colors.primary else MaterialTheme.colors.onSurface.copy(ContentAlpha.disabled), 49 | ) 50 | 51 | Spacer(Modifier.height(4.dp)) 52 | 53 | Text( 54 | text = selectedItem, 55 | modifier = Modifier.fillMaxWidth(), 56 | ) 57 | } 58 | 59 | DropdownMenu( 60 | expanded = expanded, 61 | onDismissRequest = { expanded = false }, 62 | ) { 63 | items.forEachIndexed { index, text -> 64 | DropdownMenuItem( 65 | onClick = { 66 | expanded = false 67 | onSelect(text) 68 | }, 69 | ) { 70 | Text(text = text) 71 | } 72 | } 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/main/kotlin/view/pages/info/InfoDialog.kt: -------------------------------------------------------------------------------- 1 | package view.pages.info 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.Row 7 | import androidx.compose.foundation.layout.Spacer 8 | import androidx.compose.foundation.layout.fillMaxSize 9 | import androidx.compose.foundation.layout.height 10 | import androidx.compose.foundation.layout.size 11 | import androidx.compose.foundation.layout.width 12 | import androidx.compose.foundation.layout.wrapContentSize 13 | import androidx.compose.material.MaterialTheme 14 | import androidx.compose.material.Surface 15 | import androidx.compose.material.Text 16 | import androidx.compose.runtime.Composable 17 | import androidx.compose.ui.Alignment 18 | import androidx.compose.ui.Modifier 19 | import androidx.compose.ui.res.loadImageBitmap 20 | import androidx.compose.ui.res.painterResource 21 | import androidx.compose.ui.res.useResource 22 | import androidx.compose.ui.unit.dp 23 | import androidx.compose.ui.window.Dialog 24 | import view.resource.Images 25 | import view.resource.MainTheme 26 | import view.resource.Strings 27 | import view.resource.Strings.INFO_TITLE 28 | 29 | @Composable 30 | fun InfoDialog( 31 | isDark: Boolean, 32 | onClose: () -> Unit, 33 | ) { 34 | MainTheme(isDarkMode = isDark) { 35 | Dialog( 36 | onCloseRequest = onClose, 37 | title = INFO_TITLE, 38 | icon = painterResource(Images.TRAY), 39 | ) { 40 | Surface { 41 | InfoContent(modifier = Modifier.fillMaxSize()) 42 | } 43 | } 44 | } 45 | } 46 | 47 | @Composable 48 | private fun InfoContent(modifier: Modifier = Modifier) { 49 | Box(modifier) { 50 | Row(Modifier.wrapContentSize().align(Alignment.Center)) { 51 | Box(modifier = Modifier.height(100.dp)) { 52 | Image( 53 | bitmap = useResource("icon.png") { loadImageBitmap(it) }, 54 | contentDescription = "icon", 55 | modifier = Modifier.size(44.dp).align(Alignment.Center), 56 | ) 57 | } 58 | 59 | Spacer(modifier = Modifier.width(16.dp)) 60 | 61 | Box(modifier = Modifier.height(100.dp)) { 62 | Column(modifier = Modifier.align(Alignment.Center)) { 63 | Text( 64 | text = Strings.APP_NAME, 65 | style = MaterialTheme.typography.h4, 66 | modifier = Modifier.align(Alignment.CenterHorizontally), 67 | ) 68 | Text( 69 | text = "Version ${Strings.VERSION}", 70 | style = MaterialTheme.typography.h5, 71 | modifier = Modifier.align(Alignment.CenterHorizontally), 72 | ) 73 | } 74 | } 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/main/kotlin/view/pages/setting/SettingPageStateHolder.kt: -------------------------------------------------------------------------------- 1 | package view.pages.setting 2 | 3 | import kotlinx.coroutines.flow.MutableStateFlow 4 | import kotlinx.coroutines.flow.StateFlow 5 | import kotlinx.coroutines.launch 6 | import model.entity.Setting 7 | import model.entity.Theme 8 | import model.usecase.FetchSettingUseCase 9 | import model.usecase.UpdateSettingUseCase 10 | import view.StateHolder 11 | 12 | class SettingPageStateHolder( 13 | private val fetchSettingUseCase: FetchSettingUseCase, 14 | private val updateSettingUseCase: UpdateSettingUseCase, 15 | ) : StateHolder() { 16 | private val _adbLocation: MutableStateFlow = MutableStateFlow("") 17 | val adbLocation: StateFlow = _adbLocation 18 | 19 | private val _scrcpyLocation: MutableStateFlow = MutableStateFlow("") 20 | val scrcpyLocation: StateFlow = _scrcpyLocation 21 | 22 | private val _screenRecordDirectory: MutableStateFlow = MutableStateFlow("") 23 | val screenRecordDirectory: StateFlow = _screenRecordDirectory 24 | 25 | private val _screenshotDirectory: MutableStateFlow = MutableStateFlow("") 26 | val screenshotDirectory: StateFlow = _screenshotDirectory 27 | 28 | private val _theme: MutableStateFlow = MutableStateFlow(Theme.LIGHT) 29 | val theme: StateFlow = _theme 30 | val themes: StateFlow> = MutableStateFlow(Theme.values().toList()) 31 | 32 | init { 33 | coroutineScope.launch { 34 | val setting = fetchSettingUseCase.execute() 35 | _adbLocation.value = setting.adbLocation 36 | _scrcpyLocation.value = setting.scrcpyLocation 37 | _screenRecordDirectory.value = setting.screenRecordDirectory 38 | _screenshotDirectory.value = setting.screenshotDirectory 39 | _theme.value = setting.theme 40 | } 41 | } 42 | 43 | fun updateAdbLocation(location: String) { 44 | _adbLocation.value = location 45 | } 46 | 47 | fun updateScrcpyLocation(location: String) { 48 | _scrcpyLocation.value = location 49 | } 50 | 51 | fun updateScreenshotDirectory(directory: String) { 52 | _screenshotDirectory.value = directory 53 | } 54 | 55 | fun updateScreenRecordDirectory(directory: String) { 56 | _screenRecordDirectory.value = directory 57 | } 58 | 59 | fun updateTheme(theme: Theme) { 60 | _theme.value = theme 61 | } 62 | 63 | fun save(onSaved: () -> Unit) { 64 | coroutineScope.launch { 65 | updateSettingUseCase.execute( 66 | Setting( 67 | adbLocation = _adbLocation.value, 68 | theme = _theme.value, 69 | scrcpyLocation = _scrcpyLocation.value, 70 | screenRecordDirectory = _screenRecordDirectory.value, 71 | screenshotDirectory = _screenshotDirectory.value, 72 | ), 73 | ) 74 | onSaved.invoke() 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /.idea/libraries-with-intellij-classes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 64 | 65 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if %ERRORLEVEL% equ 0 goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if %ERRORLEVEL% equ 0 goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | set EXIT_CODE=%ERRORLEVEL% 84 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 85 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 86 | exit /b %EXIT_CODE% 87 | 88 | :mainEnd 89 | if "%OS%"=="Windows_NT" endlocal 90 | 91 | :omega 92 | -------------------------------------------------------------------------------- /src/main/kotlin/model/repository/DeviceRepository.kt: -------------------------------------------------------------------------------- 1 | package model.repository 2 | 3 | import com.malinskiy.adam.AndroidDebugBridgeClientFactory 4 | import com.malinskiy.adam.request.device.ListDevicesRequest 5 | import com.malinskiy.adam.request.framebuffer.RawImageScreenCaptureAdapter 6 | import com.malinskiy.adam.request.framebuffer.ScreenCaptureRequest 7 | import kotlinx.coroutines.Dispatchers 8 | import kotlinx.coroutines.withContext 9 | import kotlinx.serialization.decodeFromString 10 | import kotlinx.serialization.encodeToString 11 | import kotlinx.serialization.json.Json 12 | import model.entity.Device 13 | import model.os.OSContext 14 | import java.io.File 15 | import javax.imageio.ImageIO 16 | 17 | class DeviceRepository(private val osContext: OSContext) { 18 | private val adb = AndroidDebugBridgeClientFactory().build() 19 | private val screenshotAdapter = RawImageScreenCaptureAdapter() 20 | 21 | suspend fun getAll(): List { 22 | return withContext(Dispatchers.IO) { 23 | val devices: List = adb.execute(request = ListDevicesRequest()).toDeviceList() 24 | loadCaches(devices) 25 | } 26 | } 27 | 28 | suspend fun saveDeviceSetting(context: Device.Context) { 29 | withContext(Dispatchers.IO) { 30 | createDir() 31 | writeCache(context) 32 | } 33 | } 34 | 35 | suspend fun saveScreenshot( 36 | device: Device, 37 | filePath: String, 38 | ): Boolean { 39 | return withContext(Dispatchers.IO) { 40 | val image = 41 | adb.execute( 42 | request = ScreenCaptureRequest(screenshotAdapter), 43 | serial = device.id, 44 | ).toBufferedImage() 45 | ImageIO.write(image, "png", File(filePath)) 46 | } 47 | } 48 | 49 | private fun writeCache(deviceContext: Device.Context) { 50 | try { 51 | File(osContext.settingPath + deviceContext.device.id).outputStream().apply { 52 | this.write(Json.encodeToString(deviceContext).toByteArray()) 53 | this.close() 54 | } 55 | } catch (e: Exception) { 56 | return 57 | } 58 | } 59 | 60 | private fun loadCaches(devices: List): List { 61 | return devices.map { loadCache(it) } 62 | } 63 | 64 | private fun loadCache(device: Device): Device.Context { 65 | return try { 66 | val content = File(osContext.settingPath + device.id).readText() 67 | Json.decodeFromString(string = content) 68 | } catch (e: Exception) { 69 | Device.Context(device = device) 70 | } 71 | } 72 | 73 | private fun createDir() { 74 | try { 75 | val file = File(osContext.settingPath) 76 | if (!file.exists()) file.mkdir() 77 | } catch (e: Exception) { 78 | return 79 | } 80 | } 81 | 82 | private fun List.toDeviceList(): List { 83 | return this.map { it.toDevice() } 84 | } 85 | 86 | private fun com.malinskiy.adam.request.device.Device.toDevice(): Device { 87 | return Device(id = serial) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/main/kotlin/model/repository/ProcessRepository.kt: -------------------------------------------------------------------------------- 1 | package model.repository 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | import kotlinx.coroutines.Dispatchers 5 | import kotlinx.coroutines.SupervisorJob 6 | import kotlinx.coroutines.delay 7 | import kotlinx.coroutines.launch 8 | import kotlinx.coroutines.withTimeout 9 | import model.command.KillCommand 10 | import model.command.ScrcpyCommand 11 | import model.command.ScrcpyCommandCreator 12 | import model.entity.Device 13 | import java.util.Date 14 | 15 | private data class ProcessState( 16 | val value: Process, 17 | val status: ProcessStatus, 18 | ) 19 | 20 | sealed class ProcessStatus { 21 | object Idle : ProcessStatus() 22 | 23 | data class Running(val startDate: Date = Date()) : ProcessStatus() 24 | 25 | data class Recording(val startDate: Date = Date()) : ProcessStatus() 26 | } 27 | 28 | class ProcessRepository( 29 | val killCommand: KillCommand, 30 | ) { 31 | private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main + Dispatchers.IO) 32 | 33 | fun addMirroringProcess( 34 | context: Device.Context, 35 | scrcpyLocation: String, 36 | onDestroy: (suspend () -> Unit)? = null, 37 | ) { 38 | val process = ScrcpyCommand(ScrcpyCommandCreator(scrcpyLocation)).run(context) 39 | processList[context.device.id] = ProcessState(process, ProcessStatus.Running()) 40 | scope.launch(Dispatchers.IO) { 41 | process.waitForRunning(MONITORING_DELAY) 42 | process.monitor(MONITORING_INTERVAL) { 43 | processList.remove(context.device.id) 44 | onDestroy?.invoke() 45 | } 46 | } 47 | } 48 | 49 | fun addRecordingProcess( 50 | context: Device.Context, 51 | fileName: String, 52 | commandLocation: String, 53 | onDestroy: (suspend () -> Unit)? = null, 54 | ) { 55 | val process = ScrcpyCommand(ScrcpyCommandCreator(commandLocation)).record(context, fileName) 56 | processList[context.device.id] = ProcessState(process, ProcessStatus.Recording()) 57 | scope.launch(Dispatchers.IO) { 58 | process.waitForRunning(MONITORING_DELAY) 59 | process.monitor(MONITORING_INTERVAL) { 60 | processList.remove(context.device.id) 61 | onDestroy?.invoke() 62 | } 63 | } 64 | } 65 | 66 | fun delete(key: String) { 67 | processList[key]?.let { 68 | killCommand.run(it.value.pid()) 69 | } 70 | processList.remove(key) 71 | } 72 | 73 | fun getStatus(key: String): ProcessStatus { 74 | return processList[key]?.status ?: ProcessStatus.Idle 75 | } 76 | 77 | private suspend fun Process.waitForRunning(interval: Long) { 78 | withTimeout(TIMEOUT) { 79 | while (!this@waitForRunning.isAlive) { 80 | delay(interval) 81 | } 82 | } 83 | } 84 | 85 | private suspend fun Process.monitor( 86 | interval: Long, 87 | onDestroy: suspend () -> Unit, 88 | ) { 89 | while (this.isAlive) { 90 | delay(interval) 91 | } 92 | onDestroy.invoke() 93 | } 94 | 95 | companion object { 96 | private const val TIMEOUT = 10000L 97 | private const val MONITORING_DELAY = 1000L 98 | private const val MONITORING_INTERVAL = 1000L 99 | private val processList: MutableMap = mutableMapOf() 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/main/kotlin/view/pages/devices/DevicesPageForMini.kt: -------------------------------------------------------------------------------- 1 | package view.pages.devices 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.fillMaxSize 5 | import androidx.compose.material.CircularProgressIndicator 6 | import androidx.compose.material.MaterialTheme 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.runtime.DisposableEffect 9 | import androidx.compose.runtime.collectAsState 10 | import androidx.compose.runtime.getValue 11 | import androidx.compose.ui.Alignment 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.window.WindowScope 14 | import model.entity.Device 15 | import view.components.DevicePager 16 | import view.parts.Texts 17 | import view.parts.TopPageMiniHeader 18 | import view.resource.Strings 19 | import view.resource.Strings.DEVICES_PAGE_ERROR_STARTING_ADB_SERVER 20 | import view.resource.Strings.DEVICES_PAGE_NOT_FOUND_DEVICES 21 | import view.templates.MainLayout 22 | 23 | @Composable 24 | fun DevicesPageForMini( 25 | windowScope: WindowScope, 26 | stateHolder: DevicesPageStateHolder, 27 | onNavigateSetting: (() -> Unit)? = null, 28 | onNavigateDevice: ((Device.Context) -> Unit)? = null, 29 | ) { 30 | val state: DevicesPageState by stateHolder.states.collectAsState() 31 | 32 | DisposableEffect(stateHolder) { 33 | stateHolder.onStarted() 34 | onDispose { 35 | stateHolder.onCleared() 36 | } 37 | } 38 | 39 | MainLayout( 40 | header = { 41 | TopPageMiniHeader( 42 | windowScope = windowScope, 43 | title = Strings.APP_NAME, 44 | ) 45 | }, 46 | content = { 47 | when (val state = state) { 48 | DevicesPageState.Loading -> { 49 | Box(modifier = Modifier.fillMaxSize()) { 50 | CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) 51 | } 52 | } 53 | 54 | DevicesPageState.Error -> { 55 | Box(modifier = Modifier.fillMaxSize()) { 56 | Texts.Subtitle1( 57 | text = DEVICES_PAGE_ERROR_STARTING_ADB_SERVER, 58 | color = MaterialTheme.colors.onBackground, 59 | modifier = Modifier.align(Alignment.Center), 60 | ) 61 | } 62 | } 63 | 64 | is DevicesPageState.DeviceExist -> { 65 | DevicePager( 66 | deviceStatusList = state.devices, 67 | startScrcpy = { stateHolder.startScrcpy(it) }, 68 | stopScrcpy = { stateHolder.stopScrcpy(it) }, 69 | goToDetail = { onNavigateDevice?.invoke(it) }, 70 | takeScreenshot = { stateHolder.saveScreenshotToDesktop(it) }, 71 | startRecording = { stateHolder.startScrcpyRecord(it) }, 72 | stopRecording = { stateHolder.stopScrcpyRecord(it) }, 73 | ) 74 | } 75 | 76 | DevicesPageState.DeviceIsEmpty -> { 77 | Box(modifier = Modifier.fillMaxSize()) { 78 | Texts.Subtitle1( 79 | text = DEVICES_PAGE_NOT_FOUND_DEVICES, 80 | color = MaterialTheme.colors.onBackground, 81 | modifier = Modifier.align(Alignment.Center), 82 | ) 83 | } 84 | } 85 | } 86 | }, 87 | ) 88 | } 89 | -------------------------------------------------------------------------------- /src/main/kotlin/view/parts/TextFieldAndError.kt: -------------------------------------------------------------------------------- 1 | package view.parts 2 | 3 | import androidx.compose.desktop.ui.tooling.preview.Preview 4 | import androidx.compose.foundation.Image 5 | import androidx.compose.foundation.clickable 6 | import androidx.compose.foundation.indication 7 | import androidx.compose.foundation.interaction.MutableInteractionSource 8 | import androidx.compose.foundation.layout.Column 9 | import androidx.compose.foundation.layout.fillMaxWidth 10 | import androidx.compose.foundation.layout.padding 11 | import androidx.compose.foundation.shape.CircleShape 12 | import androidx.compose.material.MaterialTheme 13 | import androidx.compose.material.Text 14 | import androidx.compose.material.TextField 15 | import androidx.compose.material.ripple.rememberRipple 16 | import androidx.compose.runtime.Composable 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.draw.clip 19 | import androidx.compose.ui.graphics.ColorFilter 20 | import androidx.compose.ui.graphics.vector.ImageVector 21 | import androidx.compose.ui.text.style.TextOverflow 22 | import androidx.compose.ui.unit.dp 23 | 24 | @Composable 25 | fun TextFieldAndError( 26 | label: String, 27 | placeHolder: String, 28 | inputText: String, 29 | onUpdateInputText: (String) -> Unit, 30 | error: String = "", 31 | trailingIcon: ImageVector? = null, 32 | onClickTrailingIcon: () -> Unit = {}, 33 | modifier: Modifier = Modifier, 34 | ) { 35 | Column(modifier = modifier) { 36 | TextField( 37 | value = inputText, 38 | onValueChange = { onUpdateInputText(it) }, 39 | modifier = Modifier.fillMaxWidth(), 40 | maxLines = 1, 41 | label = { Text(label) }, 42 | placeholder = { Text(placeHolder) }, 43 | isError = error.isNotEmpty(), 44 | trailingIcon = 45 | trailingIcon?.let { 46 | { 47 | Image( 48 | imageVector = trailingIcon, 49 | colorFilter = ColorFilter.tint(MaterialTheme.colors.secondary), 50 | contentDescription = null, 51 | modifier = 52 | Modifier 53 | .clip(CircleShape) 54 | .indication(MutableInteractionSource(), rememberRipple()) 55 | .clickable(onClick = { onClickTrailingIcon() }) 56 | .padding(4.dp), 57 | ) 58 | } 59 | }, 60 | ) 61 | 62 | if (error.isNotEmpty()) { 63 | Text( 64 | text = error, 65 | color = MaterialTheme.colors.error, 66 | modifier = Modifier.padding(start = 16.dp, top = 4.dp), 67 | overflow = TextOverflow.Ellipsis, 68 | style = MaterialTheme.typography.caption, 69 | ) 70 | } 71 | } 72 | } 73 | 74 | @Preview 75 | @Composable 76 | private fun TextFieldAndError_Preview() { 77 | TextFieldAndError( 78 | label = "LABEL", 79 | placeHolder = "PLACEHOLDER", 80 | inputText = "INPUT TEXT", 81 | onUpdateInputText = {}, 82 | modifier = Modifier, 83 | ) 84 | } 85 | 86 | @Preview 87 | @Composable 88 | private fun TextFieldAndError_HAS_ERROR_Preview() { 89 | TextFieldAndError( 90 | label = "LABEL", 91 | placeHolder = "PLACEHOLDER", 92 | inputText = "INPUT TEXT", 93 | error = "ERROR MEASSAGE", 94 | onUpdateInputText = {}, 95 | modifier = Modifier, 96 | ) 97 | } 98 | -------------------------------------------------------------------------------- /src/main/kotlin/view/components/DevicePager.kt: -------------------------------------------------------------------------------- 1 | package view.components 2 | 3 | import androidx.compose.foundation.ExperimentalFoundationApi 4 | import androidx.compose.foundation.background 5 | import androidx.compose.foundation.clickable 6 | import androidx.compose.foundation.layout.Arrangement 7 | import androidx.compose.foundation.layout.Box 8 | import androidx.compose.foundation.layout.Column 9 | import androidx.compose.foundation.layout.Row 10 | import androidx.compose.foundation.layout.Spacer 11 | import androidx.compose.foundation.layout.fillMaxSize 12 | import androidx.compose.foundation.layout.fillMaxWidth 13 | import androidx.compose.foundation.layout.height 14 | import androidx.compose.foundation.layout.padding 15 | import androidx.compose.foundation.layout.size 16 | import androidx.compose.foundation.layout.wrapContentHeight 17 | import androidx.compose.foundation.layout.wrapContentSize 18 | import androidx.compose.foundation.pager.HorizontalPager 19 | import androidx.compose.foundation.pager.rememberPagerState 20 | import androidx.compose.foundation.shape.CircleShape 21 | import androidx.compose.runtime.Composable 22 | import androidx.compose.runtime.rememberCoroutineScope 23 | import androidx.compose.ui.Alignment 24 | import androidx.compose.ui.Modifier 25 | import androidx.compose.ui.draw.clip 26 | import androidx.compose.ui.graphics.Color 27 | import androidx.compose.ui.unit.dp 28 | import kotlinx.coroutines.launch 29 | import model.entity.Device 30 | import view.pages.devices.DeviceStatus 31 | 32 | @OptIn(ExperimentalFoundationApi::class) 33 | @Composable 34 | fun DevicePager( 35 | deviceStatusList: List, 36 | startScrcpy: ((Device.Context) -> Unit), 37 | stopScrcpy: ((Device.Context) -> Unit), 38 | goToDetail: ((Device.Context) -> Unit), 39 | takeScreenshot: ((Device.Context) -> Unit), 40 | startRecording: ((Device.Context) -> Unit), 41 | stopRecording: ((Device.Context) -> Unit), 42 | ) { 43 | Box(modifier = Modifier.fillMaxSize()) { 44 | Column( 45 | modifier = 46 | Modifier 47 | .fillMaxWidth() 48 | .wrapContentHeight() 49 | .align(Alignment.Center) 50 | .padding(8.dp), 51 | ) { 52 | val pageCount = deviceStatusList.count() 53 | val pagerState = rememberPagerState(pageCount = { pageCount }) 54 | val coroutineScope = rememberCoroutineScope() 55 | 56 | HorizontalPager(state = pagerState) { page -> 57 | DeviceCard( 58 | deviceStatusList[page], 59 | false, 60 | startScrcpy, 61 | stopScrcpy, 62 | goToDetail, 63 | takeScreenshot, 64 | startRecording, 65 | stopRecording, 66 | modifier = Modifier.wrapContentSize(), 67 | ) 68 | } 69 | 70 | if (1 < deviceStatusList.count()) { 71 | Spacer(modifier = Modifier.height(8.dp)) 72 | 73 | Row( 74 | Modifier.wrapContentSize().align(Alignment.CenterHorizontally), 75 | horizontalArrangement = Arrangement.spacedBy(8.dp), 76 | ) { 77 | repeat(pageCount) { iteration -> 78 | val color = if (pagerState.currentPage == iteration) Color.DarkGray else Color.LightGray 79 | Box( 80 | modifier = 81 | Modifier 82 | .clip(CircleShape) 83 | .background(color) 84 | .size(12.dp) 85 | .clickable { coroutineScope.launch { pagerState.animateScrollToPage(iteration) } }, 86 | ) 87 | } 88 | } 89 | } 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/main/kotlin/ScrcpyHub.kt: -------------------------------------------------------------------------------- 1 | import androidx.compose.runtime.collectAsState 2 | import androidx.compose.runtime.getValue 3 | import androidx.compose.runtime.mutableStateOf 4 | import androidx.compose.runtime.remember 5 | import androidx.compose.runtime.setValue 6 | import androidx.compose.ui.res.painterResource 7 | import androidx.compose.ui.unit.dp 8 | import androidx.compose.ui.window.Tray 9 | import androidx.compose.ui.window.application 10 | import androidx.compose.ui.window.rememberTrayState 11 | import androidx.compose.ui.window.rememberWindowState 12 | import model.di.appModule 13 | import org.koin.core.context.GlobalContext 14 | import org.koin.core.context.GlobalContext.getOrNull 15 | import org.koin.core.context.GlobalContext.startKoin 16 | import view.MainContent 17 | import view.MainContentStateHolder 18 | import view.MainWindow 19 | import view.pages.info.InfoDialog 20 | import view.pages.license.LicenseDialog 21 | import view.resource.Images 22 | import view.resource.Strings 23 | 24 | fun main() = 25 | application { 26 | if (getOrNull() == null) { 27 | startKoin { 28 | modules(appModule) 29 | } 30 | } 31 | 32 | val trayState = rememberTrayState() 33 | var isOpen by remember { mutableStateOf(true) } 34 | var showLicense by remember { mutableStateOf(false) } 35 | var showInfo by remember { mutableStateOf(false) } 36 | var alwaysOnTop by remember { mutableStateOf(false) } 37 | var enableMiniMode by remember { mutableStateOf(false) } 38 | 39 | Tray( 40 | state = trayState, 41 | icon = painterResource(Images.TRAY), 42 | menu = { 43 | CheckboxItem( 44 | text = Strings.TRAY_SHOW_SCRCPY_HUB, 45 | checked = isOpen, 46 | onCheckedChange = { isOpen = it }, 47 | ) 48 | 49 | CheckboxItem( 50 | text = Strings.TRAY_ENABLE_ALWAYS_TOP, 51 | checked = alwaysOnTop, 52 | onCheckedChange = { alwaysOnTop = it }, 53 | ) 54 | 55 | CheckboxItem( 56 | text = Strings.TRAY_ENABLE_MINI_MODE, 57 | checked = enableMiniMode, 58 | onCheckedChange = { enableMiniMode = it }, 59 | ) 60 | 61 | Separator() 62 | 63 | CheckboxItem( 64 | text = Strings.TRAY_ABOUT_LICENSE, 65 | checked = showLicense, 66 | onCheckedChange = { showLicense = it }, 67 | ) 68 | 69 | CheckboxItem( 70 | text = Strings.TRAY_ABOUT_SCRCPYHUB, 71 | checked = showInfo, 72 | onCheckedChange = { showInfo = it }, 73 | ) 74 | 75 | Separator() 76 | 77 | Item( 78 | Strings.QUIT, 79 | onClick = { exitApplication() }, 80 | ) 81 | }, 82 | ) 83 | 84 | if (isOpen) { 85 | val stateHolder by remember { mutableStateOf(GlobalContext.get().get()) } 86 | val isDarkMode: Boolean? by stateHolder.isDarkMode.collectAsState(null) 87 | 88 | if (enableMiniMode) { 89 | val windowState = rememberWindowState(width = 350.dp, height = 160.dp) 90 | MainWindow( 91 | onCloseRequest = { isOpen = false }, 92 | state = windowState, 93 | alwaysOnTop = alwaysOnTop, 94 | ) { 95 | MainContent( 96 | windowScope = this, 97 | enableMiniMode = enableMiniMode, 98 | mainStateHolder = stateHolder, 99 | ) 100 | } 101 | } else { 102 | val windowState = rememberWindowState(width = 350.dp, height = 550.dp) 103 | MainWindow( 104 | onCloseRequest = { isOpen = false }, 105 | state = windowState, 106 | alwaysOnTop = alwaysOnTop, 107 | ) { 108 | MainContent( 109 | windowScope = this, 110 | enableMiniMode = enableMiniMode, 111 | mainStateHolder = stateHolder, 112 | ) 113 | } 114 | } 115 | 116 | if (showLicense) { 117 | LicenseDialog( 118 | isDark = isDarkMode ?: false, 119 | onClose = { showLicense = false }, 120 | ) 121 | } 122 | 123 | if (showInfo) { 124 | InfoDialog( 125 | isDark = isDarkMode ?: false, 126 | onClose = { showInfo = false }, 127 | ) 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/main/kotlin/view/pages/devices/DevicesPageStateHolder.kt: -------------------------------------------------------------------------------- 1 | package view.pages.devices 2 | 3 | import kotlinx.coroutines.delay 4 | import kotlinx.coroutines.flow.MutableStateFlow 5 | import kotlinx.coroutines.flow.SharingStarted 6 | import kotlinx.coroutines.flow.StateFlow 7 | import kotlinx.coroutines.flow.collectLatest 8 | import kotlinx.coroutines.flow.combine 9 | import kotlinx.coroutines.flow.map 10 | import kotlinx.coroutines.flow.stateIn 11 | import kotlinx.coroutines.launch 12 | import model.entity.Device 13 | import model.entity.Message 14 | import model.usecase.FetchDevicesUseCase 15 | import model.usecase.GetErrorMessageFlowUseCase 16 | import model.usecase.GetNotifyMessageFlowUseCase 17 | import model.usecase.GetScrcpyStatusUseCase 18 | import model.usecase.SaveScreenshotUseCase 19 | import model.usecase.StartScrcpyRecordUseCase 20 | import model.usecase.StartScrcpyUseCase 21 | import model.usecase.StopScrcpyRecordUseCase 22 | import model.usecase.StopScrcpyUseCase 23 | import view.StateHolder 24 | 25 | class DevicesPageStateHolder( 26 | private val fetchDevicesUseCase: FetchDevicesUseCase, 27 | private val startScrcpyUseCase: StartScrcpyUseCase, 28 | private val stopScrcpyUseCase: StopScrcpyUseCase, 29 | private val startScrcpyRecordUseCase: StartScrcpyRecordUseCase, 30 | private val stopScrcpyRecordUseCase: StopScrcpyRecordUseCase, 31 | private val getScrcpyProcessStatusUseCase: GetScrcpyStatusUseCase, 32 | private val saveScreenshotToDesktop: SaveScreenshotUseCase, 33 | private val getNotifyMessageFlowUseCase: GetNotifyMessageFlowUseCase, 34 | private val getErrorMessageFlowUseCase: GetErrorMessageFlowUseCase, 35 | ) : StateHolder() { 36 | private val deviceStatusList: MutableStateFlow> = MutableStateFlow(emptyList()) 37 | val states: StateFlow = 38 | deviceStatusList.map { devices -> 39 | return@map if (devices.isNotEmpty()) { 40 | DevicesPageState.DeviceExist(devices) 41 | } else { 42 | DevicesPageState.DeviceIsEmpty 43 | } 44 | }.stateIn(coroutineScope, SharingStarted.WhileSubscribed(), DevicesPageState.Loading) 45 | 46 | private val notifyMessage: MutableStateFlow> = MutableStateFlow(emptyList()) 47 | private val errorMessage: StateFlow> = 48 | getErrorMessageFlowUseCase().stateIn( 49 | coroutineScope, 50 | SharingStarted.WhileSubscribed(), 51 | emptySet(), 52 | ) 53 | val messages: StateFlow> = 54 | combine(errorMessage, notifyMessage) { error, notify -> 55 | notify + error 56 | }.stateIn(coroutineScope, SharingStarted.WhileSubscribed(), emptyList()) 57 | 58 | override fun onStarted() { 59 | observeNotifyMessage() 60 | coroutineScope.launch { 61 | while (true) { 62 | fetchStates() 63 | delay(500) 64 | } 65 | } 66 | } 67 | 68 | fun startScrcpy(context: Device.Context) { 69 | coroutineScope.launch { 70 | startScrcpyUseCase.execute(context) { fetchStates() } 71 | fetchStates() 72 | } 73 | } 74 | 75 | fun stopScrcpy(context: Device.Context) { 76 | coroutineScope.launch { 77 | stopScrcpyUseCase.execute(context) 78 | fetchStates() 79 | } 80 | } 81 | 82 | fun startScrcpyRecord(context: Device.Context) { 83 | coroutineScope.launch { 84 | startScrcpyRecordUseCase.execute(context) { fetchStates() } 85 | fetchStates() 86 | } 87 | } 88 | 89 | fun stopScrcpyRecord(context: Device.Context) { 90 | coroutineScope.launch { 91 | stopScrcpyRecordUseCase.execute(context) 92 | fetchStates() 93 | } 94 | } 95 | 96 | fun saveScreenshotToDesktop(context: Device.Context) { 97 | coroutineScope.launch { 98 | saveScreenshotToDesktop.execute(context) 99 | } 100 | } 101 | 102 | private suspend fun fetchStates() { 103 | val devices = fetchDevicesUseCase.execute() 104 | updateStates(devices) 105 | } 106 | 107 | private fun updateStates(contextList: List) { 108 | deviceStatusList.value = 109 | contextList.map { context -> 110 | DeviceStatus(context, getScrcpyProcessStatusUseCase.execute(context)) 111 | } 112 | } 113 | 114 | private fun observeNotifyMessage() { 115 | coroutineScope.launch { 116 | getNotifyMessageFlowUseCase().collectLatest { newNotifyMessage -> 117 | coroutineScope.launch { 118 | notifyMessage.value = notifyMessage.value.toMutableList().apply { add(newNotifyMessage) } 119 | delay(2000) 120 | notifyMessage.value = notifyMessage.value.toMutableList().apply { remove(newNotifyMessage) } 121 | } 122 | } 123 | } 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/main/kotlin/model/di/Module.kt: -------------------------------------------------------------------------------- 1 | package model.di 2 | 3 | import kotlinx.coroutines.runBlocking 4 | import model.command.KillCommand 5 | import model.command.KillCommandCreatorForLinux 6 | import model.command.KillCommandCreatorForMacOS 7 | import model.command.KillCommandCreatorForWindows 8 | import model.command.ScrcpyCommand 9 | import model.command.ScrcpyCommandCreator 10 | import model.entity.Device 11 | import model.os.OSContext 12 | import model.os.OSContextForLinux 13 | import model.os.OSContextForMac 14 | import model.os.OSContextForWindows 15 | import model.os.OSType 16 | import model.os.getOSType 17 | import model.repository.DeviceRepository 18 | import model.repository.MessageRepository 19 | import model.repository.ProcessRepository 20 | import model.repository.SettingRepository 21 | import model.usecase.CheckSetupStatusUseCase 22 | import model.usecase.FetchDevicesUseCase 23 | import model.usecase.FetchSettingUseCase 24 | import model.usecase.GetErrorMessageFlowUseCase 25 | import model.usecase.GetNotifyMessageFlowUseCase 26 | import model.usecase.GetScrcpyStatusUseCase 27 | import model.usecase.GetSystemDarkModeFlowUseCase 28 | import model.usecase.RestartAdbServerUseCase 29 | import model.usecase.SaveScreenshotUseCase 30 | import model.usecase.StartScrcpyRecordUseCase 31 | import model.usecase.StartScrcpyUseCase 32 | import model.usecase.StopScrcpyRecordUseCase 33 | import model.usecase.StopScrcpyUseCase 34 | import model.usecase.UpdateDeviceSetting 35 | import model.usecase.UpdateSettingUseCase 36 | import org.koin.dsl.module 37 | import view.MainContentStateHolder 38 | import view.pages.device.DevicePageStateHolder 39 | import view.pages.devices.DevicesPageStateHolder 40 | import view.pages.setting.SettingPageStateHolder 41 | 42 | val appModule = 43 | module { 44 | single { 45 | MessageRepository() 46 | } 47 | 48 | factory { 49 | when (getOSType()) { 50 | OSType.MAC_OS -> OSContextForMac() 51 | OSType.LINUX -> OSContextForLinux() 52 | OSType.WINDOWS -> OSContextForWindows() 53 | } 54 | } 55 | 56 | factory { 57 | KillCommand( 58 | when (get().type) { 59 | OSType.MAC_OS -> KillCommandCreatorForMacOS() 60 | OSType.LINUX -> KillCommandCreatorForLinux() 61 | OSType.WINDOWS -> KillCommandCreatorForWindows() 62 | }, 63 | ) 64 | } 65 | 66 | factory { 67 | val setting = runBlocking { get().get() } 68 | ScrcpyCommand(ScrcpyCommandCreator(setting.scrcpyLocation)) 69 | } 70 | 71 | factory { 72 | ProcessRepository(get()) 73 | } 74 | 75 | factory { 76 | DeviceRepository(get()) 77 | } 78 | 79 | factory { 80 | SettingRepository(get()) 81 | } 82 | 83 | factory { 84 | FetchDevicesUseCase(get()) 85 | } 86 | 87 | factory { 88 | RestartAdbServerUseCase(get()) 89 | } 90 | 91 | factory { 92 | FetchSettingUseCase(get()) 93 | } 94 | 95 | factory { 96 | GetScrcpyStatusUseCase(get()) 97 | } 98 | 99 | factory { 100 | StartScrcpyUseCase(get(), get(), get()) 101 | } 102 | 103 | factory { 104 | SaveScreenshotUseCase(get(), get(), get()) 105 | } 106 | 107 | factory { 108 | StopScrcpyUseCase(get(), get()) 109 | } 110 | 111 | factory { 112 | CheckSetupStatusUseCase(get(), get()) 113 | } 114 | 115 | factory { 116 | UpdateSettingUseCase(get()) 117 | } 118 | 119 | factory { 120 | UpdateDeviceSetting(get()) 121 | } 122 | 123 | factory { 124 | GetNotifyMessageFlowUseCase(get()) 125 | } 126 | 127 | factory { 128 | StartScrcpyRecordUseCase(get(), get(), get()) 129 | } 130 | 131 | factory { 132 | StopScrcpyRecordUseCase(get(), get()) 133 | } 134 | 135 | factory { 136 | GetSystemDarkModeFlowUseCase() 137 | } 138 | 139 | factory { 140 | GetErrorMessageFlowUseCase(get()) 141 | } 142 | 143 | factory { 144 | DevicesPageStateHolder( 145 | get(), 146 | get(), 147 | get(), 148 | get(), 149 | get(), 150 | get(), 151 | get(), 152 | get(), 153 | get(), 154 | ) 155 | } 156 | 157 | factory { (context: Device.Context) -> 158 | DevicePageStateHolder(context, get()) 159 | } 160 | 161 | factory { 162 | MainContentStateHolder(get(), get(), get(), get()) 163 | } 164 | 165 | factory { 166 | SettingPageStateHolder(get(), get()) 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/main/kotlin/view/MainContent.kt: -------------------------------------------------------------------------------- 1 | package view 2 | 3 | import androidx.compose.animation.AnimatedVisibility 4 | import androidx.compose.animation.slideInVertically 5 | import androidx.compose.animation.slideOutVertically 6 | import androidx.compose.foundation.layout.Box 7 | import androidx.compose.foundation.layout.fillMaxSize 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.runtime.DisposableEffect 10 | import androidx.compose.runtime.collectAsState 11 | import androidx.compose.runtime.getValue 12 | import androidx.compose.runtime.mutableStateOf 13 | import androidx.compose.runtime.remember 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.window.WindowScope 16 | import org.koin.core.parameter.parametersOf 17 | import org.koin.java.KoinJavaComponent.inject 18 | import view.navigation.Navigation 19 | import view.pages.device.DevicePage 20 | import view.pages.device.DevicePageStateHolder 21 | import view.pages.devices.DevicesPage 22 | import view.pages.devices.DevicesPageForMini 23 | import view.pages.devices.DevicesPageStateHolder 24 | import view.pages.setting.SettingPage 25 | import view.pages.setting.SettingPageStateHolder 26 | import view.resource.MainTheme 27 | 28 | @Composable 29 | fun MainContent( 30 | windowScope: WindowScope, 31 | enableMiniMode: Boolean, 32 | mainStateHolder: MainContentStateHolder, 33 | ) { 34 | DisposableEffect(mainStateHolder) { 35 | mainStateHolder.onStarted() 36 | onDispose { 37 | mainStateHolder.onCleared() 38 | } 39 | } 40 | 41 | val isDarkMode: Boolean? by mainStateHolder.isDarkMode.collectAsState(null) 42 | MainTheme(isDarkMode = isDarkMode ?: true) { 43 | MainPages(windowScope, enableMiniMode, mainStateHolder) 44 | } 45 | } 46 | 47 | @Composable 48 | private fun MainPages( 49 | windowScope: WindowScope, 50 | enableMiniMode: Boolean, 51 | mainStateHolder: MainContentStateHolder, 52 | ) { 53 | val navigation: Navigation by mainStateHolder.navState.collectAsState() 54 | 55 | Box(modifier = Modifier.fillMaxSize()) { 56 | val devicesPageStateHolder by remember { 57 | val stateHolder by inject(clazz = DevicesPageStateHolder::class.java) 58 | mutableStateOf(stateHolder) 59 | } 60 | 61 | if (enableMiniMode) { 62 | DevicesPageForMini( 63 | windowScope = windowScope, 64 | stateHolder = devicesPageStateHolder, 65 | onNavigateSetting = { mainStateHolder.selectPage(Navigation.SettingPage) }, 66 | onNavigateDevice = { mainStateHolder.selectPage(Navigation.DevicePage(it)) }, 67 | ) 68 | } else { 69 | DevicesPage( 70 | windowScope = windowScope, 71 | stateHolder = devicesPageStateHolder, 72 | onNavigateSetting = { mainStateHolder.selectPage(Navigation.SettingPage) }, 73 | onNavigateDevice = { mainStateHolder.selectPage(Navigation.DevicePage(it)) }, 74 | ) 75 | } 76 | 77 | val settingPage = navigation as? Navigation.SettingPage 78 | AnimatedVisibility( 79 | visible = settingPage != null, 80 | enter = slideInVertically(initialOffsetY = { return@slideInVertically windowScope.window.height }), 81 | exit = slideOutVertically(targetOffsetY = { return@slideOutVertically windowScope.window.height * 2 }), 82 | ) { 83 | val stateHolder by remember { 84 | val viewModel by inject(clazz = SettingPageStateHolder::class.java) 85 | mutableStateOf(viewModel) 86 | } 87 | 88 | SettingPage( 89 | windowScope = windowScope, 90 | stateHolder = stateHolder, 91 | onNavigateDevices = { mainStateHolder.selectPage(Navigation.DevicesPage) }, 92 | onSaved = { 93 | mainStateHolder.onRefresh() 94 | }, 95 | ) 96 | } 97 | 98 | val devicePage = navigation as? Navigation.DevicePage 99 | AnimatedVisibility( 100 | visible = devicePage != null, 101 | enter = slideInVertically(initialOffsetY = { return@slideInVertically windowScope.window.height }), 102 | exit = slideOutVertically(targetOffsetY = { return@slideOutVertically windowScope.window.height * 2 }), 103 | ) { 104 | val devicePageViewModel by remember { 105 | val stateHolder by inject(clazz = DevicePageStateHolder::class.java) { 106 | parametersOf(devicePage!!.context) 107 | } 108 | mutableStateOf(stateHolder) 109 | } 110 | 111 | DevicePage( 112 | windowScope = windowScope, 113 | stateHolder = devicePageViewModel, 114 | onNavigateDevices = { 115 | mainStateHolder.selectPage(Navigation.DevicesPage) 116 | devicesPageStateHolder.onRefresh() 117 | }, 118 | ) 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/main/kotlin/model/command/ScrcpyCommandCreator.kt: -------------------------------------------------------------------------------- 1 | package model.command 2 | 3 | import model.entity.Device 4 | 5 | class ScrcpyCommandCreator(val scrcpyBinaryPath: String? = null) { 6 | fun create(context: Device.Context): List { 7 | return buildList { 8 | add(resolveScrcpyBinaryPath()) 9 | add(DEVICE_OPTION_NAME) 10 | add(context.device.id) 11 | 12 | if (context.enableStayAwake) { 13 | add(STAY_AWAKE_OPTION) 14 | } 15 | 16 | if (context.enableShowTouches) { 17 | add(SHOW_TOUCHES_OPTION) 18 | } 19 | 20 | if (context.enablePowerOffOnClose) { 21 | add(POWER_OFF_ON_CLOSE) 22 | } 23 | 24 | if (context.disablePowerOnOnStart) { 25 | add(DISABLE_POWER_ON_ON_START) 26 | } 27 | 28 | val maxSize = context.maxSize 29 | if (maxSize != null) { 30 | add(MAX_SIZE_OPTION_NAME) 31 | add(maxSize.toString()) 32 | } 33 | 34 | val maxFrameRate = context.maxFrameRate 35 | if (maxFrameRate != null) { 36 | add(MAX_FRAME_RATE_OPTION_NAME) 37 | add(maxFrameRate.toString()) 38 | } 39 | 40 | val bitrate = context.bitrate 41 | if (bitrate != null) { 42 | add(BITRATE_OPTION_NAME) 43 | add(bitrate.toString() + "M") 44 | } 45 | 46 | val buffering = context.buffering 47 | if (buffering != null) { 48 | add("$DISPLAY_BUFFERING_OPTION_NAME$EQUAL$buffering") 49 | } 50 | 51 | if (context.noAudio) { 52 | add(NO_AUDIO_OPTION) 53 | } 54 | 55 | val audioBitrate = context.audioBitrate 56 | if (audioBitrate != null) { 57 | add("$AUDIO_BITRATE_OPTION$EQUAL${audioBitrate}K") 58 | } 59 | 60 | val audioBuffering = context.audioBuffering 61 | if (audioBuffering != null) { 62 | add("$AUDIO_BUFFERING_OPTION$EQUAL$audioBuffering") 63 | } 64 | 65 | add(WINDOW_TITLE_OPTION_NAME) 66 | add(context.displayName) 67 | 68 | val lockOrientation = context.lockOrientation 69 | if (lockOrientation != null) { 70 | add("$CAPTURE_ORIENTATION_OPTION_NAME$EQUAL$lockOrientation") 71 | } 72 | 73 | if (context.enableBorderless) { 74 | add(BORDERLESS_OPTION_NAME) 75 | } 76 | 77 | if (context.enableAlwaysOnTop) { 78 | add(ALWAYS_ON_TOP_OPTION_NAME) 79 | } 80 | 81 | if (context.enableFullScreen) { 82 | add(FULLSCREEN_OPTION_NAME) 83 | } 84 | 85 | val rotation = context.rotation 86 | if (rotation != null) { 87 | add("$ORIENTATION_OPTION_NAME$EQUAL$rotation") 88 | } 89 | 90 | if (context.enableHidKeyboard) { 91 | add(HID_KEYBOARD_OPTION) 92 | } 93 | 94 | if (context.enableHidMouse) { 95 | add(HID_MOUSE_OPTION) 96 | } 97 | } 98 | } 99 | 100 | fun createRecord( 101 | context: Device.Context, 102 | fileName: String, 103 | ): List { 104 | return buildList { 105 | addAll(create(context)) 106 | add(RECORD_OPTION_NAME) 107 | add(fileName) 108 | } 109 | } 110 | 111 | fun createHelp(): List { 112 | return buildList { 113 | add(resolveScrcpyBinaryPath()) 114 | add(HELP_OPTION_NAME) 115 | } 116 | } 117 | 118 | private fun resolveScrcpyBinaryPath(): String { 119 | return scrcpyBinaryPath ?: COMMAND_NAME 120 | } 121 | 122 | companion object { 123 | private const val EQUAL = "=" 124 | private const val COMMAND_NAME = "scrcpy" 125 | private const val DEVICE_OPTION_NAME = "-s" 126 | private const val MAX_SIZE_OPTION_NAME = "-m" 127 | private const val HELP_OPTION_NAME = "-h" 128 | private const val RECORD_OPTION_NAME = "-r" 129 | private const val MAX_FRAME_RATE_OPTION_NAME = "--max-fps" 130 | private const val BITRATE_OPTION_NAME = "-b" 131 | private const val WINDOW_TITLE_OPTION_NAME = "--window-title" 132 | private const val CAPTURE_ORIENTATION_OPTION_NAME = "--capture-orientation" 133 | private const val BORDERLESS_OPTION_NAME = "--window-borderless" 134 | private const val ALWAYS_ON_TOP_OPTION_NAME = "--always-on-top" 135 | private const val FULLSCREEN_OPTION_NAME = "--fullscreen" 136 | private const val ORIENTATION_OPTION_NAME = "--orientation" 137 | private const val DISPLAY_BUFFERING_OPTION_NAME = "--video-buffer" 138 | 139 | private const val NO_AUDIO_OPTION = "--no-audio" 140 | private const val AUDIO_BITRATE_OPTION = "--audio-bit-rate" 141 | private const val AUDIO_BUFFERING_OPTION = "--audio-buffer" 142 | 143 | private const val HID_KEYBOARD_OPTION = "--keyboard=uhid" 144 | private const val HID_MOUSE_OPTION = "--mouse=uhid" 145 | 146 | private const val STAY_AWAKE_OPTION = "--stay-awake" 147 | private const val SHOW_TOUCHES_OPTION = "--show-touches" 148 | private const val POWER_OFF_ON_CLOSE = "--power-off-on-close" 149 | private const val DISABLE_POWER_ON_ON_START = "--no-power-on" 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/main/kotlin/view/components/ScrcpyButtons.kt: -------------------------------------------------------------------------------- 1 | package view.components 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.Row 6 | import androidx.compose.foundation.layout.fillMaxHeight 7 | import androidx.compose.foundation.layout.fillMaxWidth 8 | import androidx.compose.foundation.layout.height 9 | import androidx.compose.foundation.layout.width 10 | import androidx.compose.material.Card 11 | import androidx.compose.material.ContentAlpha 12 | import androidx.compose.material.MaterialTheme 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.unit.dp 16 | import model.entity.Device 17 | import model.repository.ProcessStatus 18 | import view.pages.devices.DeviceStatus 19 | import view.parts.MenuButton 20 | import view.parts.MenuButtonColors 21 | import view.parts.MenuButtonStatus 22 | import view.resource.Strings 23 | 24 | @Composable 25 | fun ScrcpyButtons( 26 | deviceStatus: DeviceStatus, 27 | startScrcpy: ((Device.Context) -> Unit), 28 | stopScrcpy: ((Device.Context) -> Unit), 29 | startRecording: ((Device.Context) -> Unit), 30 | stopRecording: ((Device.Context) -> Unit), 31 | ) { 32 | Card { 33 | Row(modifier = Modifier.height(30.dp).fillMaxWidth()) { 34 | MenuButton( 35 | text = 36 | if (deviceStatus.processStatus is ProcessStatus.Recording) { 37 | Strings.DEVICES_PAGE_STOP_RECORDING 38 | } else { 39 | Strings.DEVICES_PAGE_START_RECORDING 40 | }, 41 | style = MaterialTheme.typography.subtitle2, 42 | status = RecordingButtonStatus(deviceStatus.processStatus), 43 | colors = RecordingButtonColors(), 44 | onIdleClick = { startRecording.invoke(deviceStatus.context) }, 45 | onActiveClick = { stopRecording.invoke(deviceStatus.context) }, 46 | modifier = 47 | Modifier 48 | .weight(0.2f) 49 | .fillMaxHeight(), 50 | ) 51 | 52 | Box( 53 | modifier = 54 | Modifier.fillMaxHeight().width(1.dp) 55 | .background(MaterialTheme.colors.onSurface.copy(alpha = 0.12f)), 56 | ) 57 | 58 | MenuButton( 59 | text = 60 | if (deviceStatus.processStatus is ProcessStatus.Running) { 61 | Strings.DEVICES_PAGE_STOP_MIRRORING 62 | } else { 63 | Strings.DEVICES_PAGE_START_MIRRORING 64 | }, 65 | style = MaterialTheme.typography.subtitle2, 66 | status = StartButtonStatus(deviceStatus.processStatus), 67 | colors = StartButtonColors(), 68 | onIdleClick = { startScrcpy.invoke(deviceStatus.context) }, 69 | onActiveClick = { stopScrcpy.invoke(deviceStatus.context) }, 70 | modifier = 71 | Modifier 72 | .weight(0.2f) 73 | .fillMaxHeight(), 74 | ) 75 | } 76 | } 77 | } 78 | 79 | @Composable 80 | private fun CaptureButtonStatus(): MenuButtonStatus { 81 | return MenuButtonStatus.ENABLE 82 | } 83 | 84 | @Composable 85 | private fun CaptureButtonColors(): MenuButtonColors { 86 | return MenuButtonColors( 87 | active = MaterialTheme.colors.primary, 88 | enable = MaterialTheme.colors.primary, 89 | disable = MaterialTheme.colors.onSurface.copy(alpha = 0.12f), 90 | textColor = MaterialTheme.colors.onPrimary, 91 | textColorOnDisable = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled), 92 | ) 93 | } 94 | 95 | @Composable 96 | private fun RecordingButtonStatus(processStatus: ProcessStatus): MenuButtonStatus { 97 | return when (processStatus) { 98 | is ProcessStatus.Recording -> MenuButtonStatus.ACTIVE 99 | ProcessStatus.Idle -> MenuButtonStatus.ENABLE 100 | is ProcessStatus.Running -> MenuButtonStatus.DISABLE 101 | } 102 | } 103 | 104 | @Composable 105 | private fun RecordingButtonColors(): MenuButtonColors { 106 | return MenuButtonColors( 107 | active = MaterialTheme.colors.error, 108 | enable = MaterialTheme.colors.primary, 109 | disable = MaterialTheme.colors.onSurface.copy(alpha = 0.12f), 110 | textColor = MaterialTheme.colors.onPrimary, 111 | textColorOnDisable = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled), 112 | ) 113 | } 114 | 115 | @Composable 116 | private fun StartButtonStatus(processStatus: ProcessStatus): MenuButtonStatus { 117 | return when (processStatus) { 118 | is ProcessStatus.Recording -> MenuButtonStatus.DISABLE 119 | ProcessStatus.Idle -> MenuButtonStatus.ENABLE 120 | is ProcessStatus.Running -> MenuButtonStatus.ACTIVE 121 | } 122 | } 123 | 124 | @Composable 125 | private fun StartButtonColors(): MenuButtonColors { 126 | return MenuButtonColors( 127 | active = MaterialTheme.colors.error, 128 | enable = MaterialTheme.colors.primary, 129 | disable = MaterialTheme.colors.onSurface.copy(alpha = 0.12f), 130 | textColor = MaterialTheme.colors.onPrimary, 131 | textColorOnDisable = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled), 132 | ) 133 | } 134 | 135 | @Composable 136 | private fun StopButtonStatus(processStatus: ProcessStatus): MenuButtonStatus { 137 | return when (processStatus) { 138 | is ProcessStatus.Recording -> MenuButtonStatus.ENABLE 139 | is ProcessStatus.Idle -> MenuButtonStatus.DISABLE 140 | is ProcessStatus.Running -> MenuButtonStatus.ENABLE 141 | } 142 | } 143 | 144 | @Composable 145 | private fun StopButtonColors(): MenuButtonColors { 146 | return MenuButtonColors( 147 | active = MaterialTheme.colors.primary, 148 | enable = MaterialTheme.colors.primary, 149 | disable = MaterialTheme.colors.onSurface.copy(alpha = 0.12f), 150 | textColor = MaterialTheme.colors.onPrimary, 151 | textColorOnDisable = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled), 152 | ) 153 | } 154 | -------------------------------------------------------------------------------- /src/main/kotlin/view/resource/Strings.kt: -------------------------------------------------------------------------------- 1 | package view.resource 2 | 3 | object Strings { 4 | // COMMON 5 | const val APP_NAME = "ScrcpyHub" 6 | const val VERSION = "v2.3.0" 7 | const val SAVE = "Save" 8 | const val CANCEL = "Cancel" 9 | const val QUIT = "Quit" 10 | 11 | // TRAY 12 | const val TRAY_SHOW_SCRCPY_HUB = "Show Window" 13 | const val TRAY_ENABLE_ALWAYS_TOP = "Enable Always on Top" 14 | const val TRAY_ENABLE_MINI_MODE = "Enable Mini Window" 15 | const val TRAY_ABOUT_SCRCPYHUB = "About ScrcpyHub" 16 | const val TRAY_ABOUT_LICENSE = "About License" 17 | 18 | // Error 19 | const val NOT_FOUND_ADB_COMMAND = "Not found ADB binary" 20 | const val NOT_FOUND_SCRCPY_COMMAND = "Not found Scrcpy binary" 21 | 22 | // Info Dialog 23 | const val INFO_TITLE = "About ScrcpyHub" 24 | 25 | // License Dialog 26 | const val LICENSE_TITLE = "About Libraries" 27 | 28 | // DEVICES PAGE 29 | const val DEVICES_PAGE_START_MIRRORING = "Start Mirroring" 30 | const val DEVICES_PAGE_STOP_MIRRORING = "Stop Mirroring" 31 | const val DEVICES_PAGE_START_RECORDING = "Start Recording" 32 | const val DEVICES_PAGE_STOP_RECORDING = "Stop Recording" 33 | const val DEVICES_PAGE_NOT_FOUND_DEVICES = "Not found devices" 34 | const val DEVICES_PAGE_ERROR_STARTING_ADB_SERVER = "Can't start adb server" 35 | 36 | // Device Page 37 | const val DEVICE_PAGE_EDIT_DEVICE_TITLE = "Device" 38 | const val DEVICE_PAGE_EDIT_NAME_TITLE = "Custom Name" 39 | const val DEVICE_PAGE_EDIT_NAME_DETAILS = "Customize your device name." 40 | const val DEVICE_PAGE_EDIT_STAY_AWAKE_TITLE = "Stay awake" 41 | const val DEVICE_PAGE_EDIT_STAY_AWAKE_DETAILS = "To prevent the device from sleeping" 42 | const val DEVICE_PAGE_EDIT_SHOW_TOUCHES_TITLE = "Show touches" 43 | const val DEVICE_PAGE_EDIT_SHOW_TOUCHES_DETAILS = "To show physical touches" 44 | const val DEVICE_PAGE_EDIT_POWER_OFF_ON_CLOSE_TITLE = "Enable power off on stop" 45 | const val DEVICE_PAGE_EDIT_POWER_OFF_ON_CLOSE_DETAILS = "To turn the device screen off on Stop" 46 | const val DEVICE_PAGE_EDIT_POWER_ON_ON_START_TITLE = "Disable power on on start" 47 | const val DEVICE_PAGE_EDIT_POWER_ON_ON_START_DETAILS = "To prevent the screen on on Start" 48 | 49 | const val DEVICE_PAGE_EDIT_VIDEO_TITLE = "Video" 50 | const val DEVICE_PAGE_EDIT_MAX_SIZE_TITLE = "Reduce size" 51 | const val DEVICE_PAGE_EDIT_MAX_SIZE_DETAILS = "To limit both the width and height." 52 | const val DEVICE_PAGE_EDIT_MAX_FRAME_RATE_TITLE = "Limit frame rate" 53 | const val DEVICE_PAGE_EDIT_MAX_FRAME_RATE_DETAILS = "The capture frame rate can be limited." 54 | const val DEVICE_PAGE_EDIT_MAX_BITRATE_TITLE = "BitRate (Mbps)" 55 | const val DEVICE_PAGE_EDIT_MAX_BITRATE_DETAILS = "To change the video bitrate." 56 | const val DEVICE_PAGE_EDIT_BUFFERING_TITLE = "Buffering (ms)" 57 | const val DEVICE_PAGE_EDIT_BUFFERING_DETAILS = "To change the buffering." 58 | const val DEVICE_PAGE_EDIT_ORIENTATION_TITLE = "Capture Orientation" 59 | const val DEVICE_PAGE_EDIT_ORIENTATION_NONE = "None" 60 | const val DEVICE_PAGE_EDIT_ORIENTATION_NATURAL = "Natural" 61 | const val DEVICE_PAGE_EDIT_ORIENTATION_COUNTER_CLOCK_WISE_90 = "90° Counter Clock Wise" 62 | const val DEVICE_PAGE_EDIT_ORIENTATION_CLOCK_WISE_180 = "180°" 63 | const val DEVICE_PAGE_EDIT_ORIENTATION_CLOCK_WISE_90 = "90° Clock Wise" 64 | 65 | const val DEVICE_PAGE_EDIT_AUDIO_TITLE = "Audio" 66 | const val DEVICE_PAGE_EDIT_AUDIO_NO_AUDIO_TITLE = "No Audio" 67 | const val DEVICE_PAGE_EDIT_AUDIO_NO_AUDIO_DETAILS = "To disable audio." 68 | const val DEVICE_PAGE_EDIT_AUDIO_BITRATE_TITLE = "BitRate (Kbsp)" 69 | const val DEVICE_PAGE_EDIT_AUDIO_BITRATE_DETAILS = "To change the audio bitrate." 70 | const val DEVICE_PAGE_EDIT_AUDIO_BUFFERING_TITLE = "Buffering(ms)" 71 | const val DEVICE_PAGE_EDIT_AUDIO_BUFFERING_DETAILS = "To change the buffering." 72 | 73 | const val DEVICE_PAGE_EDIT_WINDOW_TITLE = "Window" 74 | const val DEVICE_PAGE_EDIT_BORDERLESS_TITLE = "Borderless" 75 | const val DEVICE_PAGE_EDIT_BORDERLESS_DETAILS = "To disable window decorations" 76 | const val DEVICE_PAGE_EDIT_ALWAYS_ON_TOP_TITLE = "Always on Top" 77 | const val DEVICE_PAGE_EDIT_ALWAYS_ON_TOP_DETAILS = "To keep the scrcpy window always on top" 78 | const val DEVICE_PAGE_EDIT_FULLSCREEN_TITLE = "Fullscreen" 79 | const val DEVICE_PAGE_EDIT_FULLSCREEN_DETAILS = "The app may be started directly in fullscreen" 80 | const val DEVICE_PAGE_EDIT_ROTATION_TITLE = "Orientation (Don't Affect Recording)" 81 | const val DEVICE_PAGE_EDIT_ROTATION_NONE = "None" 82 | const val DEVICE_PAGE_EDIT_ROTATION_COUNTER_CLOCK_WISE_90 = "90° Counter Clock Wise" 83 | const val DEVICE_PAGE_EDIT_ROTATION_CLOCK_WISE_180 = "180°" 84 | const val DEVICE_PAGE_EDIT_ROTATION_CLOCK_WISE_90 = "90° Clock Wise" 85 | 86 | const val DEVICE_PAGE_EDIT_HID_TITLE = "HID" 87 | const val DEVICE_PAGE_EDIT_HID_KEYBOARD_TITLE = "Keyboard Simulation" 88 | const val DEVICE_PAGE_EDIT_HID_KEYBOARD_DETAILS = "To enable hid keyboard simulation" 89 | const val DEVICE_PAGE_EDIT_HID_MOUSE_TITLE = "Mouse Simulation" 90 | const val DEVICE_PAGE_EDIT_HID_MOUSE_DETAILS = "To enable hid mouse simulation" 91 | 92 | // Setting Page 93 | const val SETTING_PAGE_TITLE = "Preferences" 94 | const val SETTING_PAGE_EDIT_THEME_TITLE = "Theme" 95 | const val SETTING_PAGE_EDIT_BINARY_TITLE = "Binary" 96 | const val SETTING_PAGE_EDIT_ADB_LOCATION_TITLE = "ADB Location" 97 | const val SETTING_PAGE_EDIT_ADB_LOCATION_DETAILS = "If it is empty, use an environment variable" 98 | const val SETTING_PAGE_EDIT_SCRCPY_LOCATION_TITLE = "Scrcpy Location" 99 | const val SETTING_PAGE_EDIT_SCRCPY_LOCATION_DETAILS = "If it is empty, use an environment variable" 100 | const val SETTING_PAGE_EDIT_CAPTURE_AND_RECORD_TITLE = "Capture & Recording" 101 | const val SETTING_PAGE_EDIT_SCREEN_SHOT_TITLE = "Capture Directory" 102 | const val SETTING_PAGE_EDIT_SCREEN_SHOT_DETAILS = "If it is empty, save file to desktop" 103 | const val SETTING_PAGE_EDIT_SCREEN_RECORD_TITLE = "Recording Directory" 104 | const val SETTING_PAGE_EDIT_SCREEN_RECORD_DETAILS = "If it is empty, save file to desktop" 105 | 106 | const val SETTING_THEME_LIGHT = "Light" 107 | const val SETTING_THEME_DARK = "Dark" 108 | const val SETTING_THEME_SYNC_WITH_OS = "Auto" 109 | } 110 | -------------------------------------------------------------------------------- /src/main/kotlin/view/parts/Texts.kt: -------------------------------------------------------------------------------- 1 | package view.parts 2 | 3 | import androidx.compose.desktop.ui.tooling.preview.Preview 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.material.MaterialTheme 6 | import androidx.compose.material.Text 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.graphics.Color 10 | import androidx.compose.ui.text.style.TextOverflow 11 | 12 | object Texts { 13 | @Composable 14 | fun H1( 15 | text: String, 16 | color: Color = Color.Unspecified, 17 | maxLines: Int = Int.MAX_VALUE, 18 | modifier: Modifier = Modifier, 19 | ) { 20 | Text( 21 | text = text, 22 | color = color, 23 | maxLines = maxLines, 24 | modifier = modifier, 25 | overflow = TextOverflow.Ellipsis, 26 | style = MaterialTheme.typography.h1, 27 | ) 28 | } 29 | 30 | @Composable 31 | fun H2( 32 | text: String, 33 | color: Color = Color.Unspecified, 34 | maxLines: Int = Int.MAX_VALUE, 35 | modifier: Modifier = Modifier, 36 | ) { 37 | Text( 38 | text = text, 39 | color = color, 40 | maxLines = maxLines, 41 | modifier = modifier, 42 | overflow = TextOverflow.Ellipsis, 43 | style = MaterialTheme.typography.h2, 44 | ) 45 | } 46 | 47 | @Composable 48 | fun H3( 49 | text: String, 50 | color: Color = Color.Unspecified, 51 | maxLines: Int = Int.MAX_VALUE, 52 | modifier: Modifier = Modifier, 53 | ) { 54 | Text( 55 | text = text, 56 | color = color, 57 | maxLines = maxLines, 58 | modifier = modifier, 59 | overflow = TextOverflow.Ellipsis, 60 | style = MaterialTheme.typography.h3, 61 | ) 62 | } 63 | 64 | @Composable 65 | fun H4( 66 | text: String, 67 | color: Color = Color.Unspecified, 68 | maxLines: Int = Int.MAX_VALUE, 69 | modifier: Modifier = Modifier, 70 | ) { 71 | Text( 72 | text = text, 73 | color = color, 74 | maxLines = maxLines, 75 | modifier = modifier, 76 | overflow = TextOverflow.Ellipsis, 77 | style = MaterialTheme.typography.h4, 78 | ) 79 | } 80 | 81 | @Composable 82 | fun H5( 83 | text: String, 84 | color: Color = Color.Unspecified, 85 | maxLines: Int = Int.MAX_VALUE, 86 | modifier: Modifier = Modifier, 87 | ) { 88 | Text( 89 | text = text, 90 | color = color, 91 | maxLines = maxLines, 92 | modifier = modifier, 93 | overflow = TextOverflow.Ellipsis, 94 | style = MaterialTheme.typography.h5, 95 | ) 96 | } 97 | 98 | @Composable 99 | fun H6( 100 | text: String, 101 | color: Color = Color.Unspecified, 102 | maxLines: Int = Int.MAX_VALUE, 103 | modifier: Modifier = Modifier, 104 | ) { 105 | Text( 106 | text = text, 107 | color = color, 108 | maxLines = maxLines, 109 | modifier = modifier, 110 | overflow = TextOverflow.Ellipsis, 111 | style = MaterialTheme.typography.h6, 112 | ) 113 | } 114 | 115 | @Composable 116 | fun Subtitle1( 117 | text: String, 118 | color: Color = Color.Unspecified, 119 | maxLines: Int = Int.MAX_VALUE, 120 | modifier: Modifier = Modifier, 121 | ) { 122 | Text( 123 | text = text, 124 | color = color, 125 | maxLines = maxLines, 126 | modifier = modifier, 127 | overflow = TextOverflow.Ellipsis, 128 | style = MaterialTheme.typography.subtitle1, 129 | ) 130 | } 131 | 132 | @Composable 133 | fun Subtitle2( 134 | text: String, 135 | color: Color = Color.Unspecified, 136 | maxLines: Int = Int.MAX_VALUE, 137 | modifier: Modifier = Modifier, 138 | ) { 139 | Text( 140 | text = text, 141 | color = color, 142 | maxLines = maxLines, 143 | modifier = modifier, 144 | overflow = TextOverflow.Ellipsis, 145 | style = MaterialTheme.typography.subtitle2, 146 | ) 147 | } 148 | 149 | @Composable 150 | fun Body1( 151 | text: String, 152 | color: Color = Color.Unspecified, 153 | maxLines: Int = Int.MAX_VALUE, 154 | modifier: Modifier = Modifier, 155 | ) { 156 | Text( 157 | text = text, 158 | color = color, 159 | maxLines = maxLines, 160 | modifier = modifier, 161 | overflow = TextOverflow.Ellipsis, 162 | style = MaterialTheme.typography.body1, 163 | ) 164 | } 165 | 166 | @Composable 167 | fun Body2( 168 | text: String, 169 | color: Color = Color.Unspecified, 170 | maxLines: Int = Int.MAX_VALUE, 171 | modifier: Modifier = Modifier, 172 | ) { 173 | Text( 174 | text = text, 175 | color = color, 176 | maxLines = maxLines, 177 | modifier = modifier, 178 | overflow = TextOverflow.Ellipsis, 179 | style = MaterialTheme.typography.body2, 180 | ) 181 | } 182 | 183 | @Composable 184 | fun Button( 185 | text: String, 186 | color: Color = Color.Unspecified, 187 | maxLines: Int = Int.MAX_VALUE, 188 | modifier: Modifier = Modifier, 189 | ) { 190 | Text( 191 | text = text, 192 | color = color, 193 | maxLines = maxLines, 194 | modifier = modifier, 195 | overflow = TextOverflow.Ellipsis, 196 | style = MaterialTheme.typography.button, 197 | ) 198 | } 199 | 200 | @Composable 201 | fun Caption( 202 | text: String, 203 | color: Color = Color.Unspecified, 204 | maxLines: Int = Int.MAX_VALUE, 205 | modifier: Modifier = Modifier, 206 | ) { 207 | Text( 208 | text = text, 209 | color = color, 210 | maxLines = maxLines, 211 | modifier = modifier, 212 | overflow = TextOverflow.Ellipsis, 213 | style = MaterialTheme.typography.caption, 214 | ) 215 | } 216 | } 217 | 218 | @Preview 219 | @Composable 220 | private fun Texts_Preview() { 221 | Column { 222 | Texts.H1("H1") 223 | Texts.H2("H2") 224 | Texts.H3("H3") 225 | Texts.H4("H4") 226 | Texts.H5("H5") 227 | Texts.H6("H6") 228 | Texts.Subtitle1("Subtitle1") 229 | Texts.Subtitle2("Subtitle2") 230 | Texts.Body1("Body1") 231 | Texts.Body2("Body2") 232 | Texts.Button("Button") 233 | Texts.Caption("Caption") 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /src/main/kotlin/view/pages/devices/DevicesPage.kt: -------------------------------------------------------------------------------- 1 | package view.pages.devices 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.fillMaxSize 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.foundation.layout.wrapContentHeight 9 | import androidx.compose.foundation.lazy.LazyColumn 10 | import androidx.compose.foundation.lazy.items 11 | import androidx.compose.material.Card 12 | import androidx.compose.material.CircularProgressIndicator 13 | import androidx.compose.material.MaterialTheme 14 | import androidx.compose.material.Text 15 | import androidx.compose.runtime.Composable 16 | import androidx.compose.runtime.DisposableEffect 17 | import androidx.compose.runtime.collectAsState 18 | import androidx.compose.runtime.getValue 19 | import androidx.compose.ui.Alignment 20 | import androidx.compose.ui.Modifier 21 | import androidx.compose.ui.text.style.TextAlign 22 | import androidx.compose.ui.unit.dp 23 | import androidx.compose.ui.window.WindowScope 24 | import model.entity.Device 25 | import model.entity.Message 26 | import view.components.DeviceList 27 | import view.parts.Texts 28 | import view.parts.TopPageHeader 29 | import view.resource.Strings 30 | import view.resource.Strings.DEVICES_PAGE_ERROR_STARTING_ADB_SERVER 31 | import view.resource.Strings.DEVICES_PAGE_NOT_FOUND_DEVICES 32 | import view.templates.MainLayout 33 | 34 | @Composable 35 | fun DevicesPage( 36 | windowScope: WindowScope, 37 | stateHolder: DevicesPageStateHolder, 38 | onNavigateSetting: (() -> Unit)? = null, 39 | onNavigateDevice: ((Device.Context) -> Unit)? = null, 40 | ) { 41 | val state: DevicesPageState by stateHolder.states.collectAsState() 42 | val messages: List by stateHolder.messages.collectAsState() 43 | 44 | DisposableEffect(stateHolder) { 45 | stateHolder.onStarted() 46 | onDispose { 47 | stateHolder.onCleared() 48 | } 49 | } 50 | 51 | MainLayout( 52 | header = { 53 | TopPageHeader( 54 | windowScope = windowScope, 55 | title = Strings.APP_NAME, 56 | onClickOption = { onNavigateSetting?.invoke() }, 57 | ) 58 | }, 59 | content = { 60 | when (val state = state) { 61 | DevicesPageState.Loading -> { 62 | Box(modifier = Modifier.fillMaxSize()) { 63 | CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) 64 | } 65 | } 66 | 67 | DevicesPageState.Error -> { 68 | Box(modifier = Modifier.fillMaxSize()) { 69 | Texts.Subtitle1( 70 | text = DEVICES_PAGE_ERROR_STARTING_ADB_SERVER, 71 | color = MaterialTheme.colors.onBackground, 72 | modifier = Modifier.align(Alignment.Center), 73 | ) 74 | } 75 | } 76 | 77 | is DevicesPageState.DeviceExist -> { 78 | DeviceList( 79 | deviceStatusList = state.devices, 80 | startScrcpy = { stateHolder.startScrcpy(it) }, 81 | stopScrcpy = { stateHolder.stopScrcpy(it) }, 82 | goToDetail = { onNavigateDevice?.invoke(it) }, 83 | takeScreenshot = { stateHolder.saveScreenshotToDesktop(it) }, 84 | startRecording = { stateHolder.startScrcpyRecord(it) }, 85 | stopRecording = { stateHolder.stopScrcpyRecord(it) }, 86 | ) 87 | } 88 | 89 | DevicesPageState.DeviceIsEmpty -> { 90 | Box(modifier = Modifier.fillMaxSize()) { 91 | Texts.Subtitle1( 92 | text = DEVICES_PAGE_NOT_FOUND_DEVICES, 93 | color = MaterialTheme.colors.onBackground, 94 | modifier = Modifier.align(Alignment.Center), 95 | ) 96 | } 97 | } 98 | } 99 | }, 100 | snackBar = { 101 | EventMessageList(messages) 102 | }, 103 | ) 104 | } 105 | 106 | @Composable 107 | private fun EventMessageList(messages: List) { 108 | LazyColumn( 109 | verticalArrangement = Arrangement.spacedBy(8.dp), 110 | modifier = Modifier.padding(8.dp), 111 | ) { 112 | items(messages, key = { it.uuid }) { 113 | val backgroundColor = 114 | when (it) { 115 | is Message.Error, 116 | is Message.Notify.FailedMirroring, 117 | is Message.Notify.FailedRecordingMovie, 118 | is Message.Notify.FailedToSaveScreenshot, 119 | -> MaterialTheme.colors.error 120 | 121 | else -> MaterialTheme.colors.primary 122 | } 123 | val textColor = 124 | when (it) { 125 | is Message.Error, 126 | is Message.Notify.FailedMirroring, 127 | is Message.Notify.FailedRecordingMovie, 128 | is Message.Notify.FailedToSaveScreenshot, 129 | -> MaterialTheme.colors.onError 130 | 131 | else -> MaterialTheme.colors.onPrimary 132 | } 133 | Card(backgroundColor = backgroundColor) { 134 | Text( 135 | text = it.toUIMessage(), 136 | color = textColor, 137 | style = MaterialTheme.typography.body1, 138 | textAlign = TextAlign.Center, 139 | maxLines = 2, 140 | modifier = Modifier.fillMaxWidth().wrapContentHeight().padding(8.dp), 141 | ) 142 | } 143 | } 144 | } 145 | } 146 | 147 | private fun Message.toUIMessage(): String { 148 | return when (this) { 149 | Message.Error.NotFoundAdbBinary -> "Not found adb binary,\nPlease setup adb binary location" 150 | Message.Error.NotFoundScrcpyBinary -> "Not found scrcpy binary,\nPlease setup scrcpy binary location" 151 | is Message.Notify.FailedToSaveScreenshot -> "Failed to save ${this.context.displayName} Screenshot!" 152 | is Message.Notify.StartRecordingMovie -> "Start recording movie on ${this.context.displayName}" 153 | is Message.Notify.StopRecordingMovie -> "Stop recording movie on ${this.context.displayName}" 154 | is Message.Notify.FailedRecordingMovie -> "Failed recording movie on ${this.context.displayName}" 155 | is Message.Notify.SuccessToSaveScreenshot -> "Success to save ${this.context.displayName} Screenshot!" 156 | is Message.Notify.StartMirroring -> "Start mirroring on ${this.context.displayName}" 157 | is Message.Notify.StopMirroring -> "Stop mirroring on ${this.context.displayName}" 158 | is Message.Notify.FailedMirroring -> "Failed mirroring on ${this.context.displayName}" 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /src/test/kotlin/model/command/ScrcpyCommandCreatorTest.kt: -------------------------------------------------------------------------------- 1 | package model.command 2 | 3 | import io.kotest.core.spec.style.StringSpec 4 | import io.kotest.matchers.shouldBe 5 | import model.entity.Device 6 | import java.io.File.separator as fileSeparator 7 | 8 | class ScrcpyCommandCreatorTest : StringSpec({ 9 | val device1 = 10 | Device.Context( 11 | Device(id = "DEVICE1"), 12 | maxSize = null, 13 | maxFrameRate = null, 14 | bitrate = null, 15 | buffering = null, 16 | noAudio = false, 17 | audioBitrate = null, 18 | audioBuffering = null, 19 | lockOrientation = null, 20 | enableBorderless = false, 21 | enableAlwaysOnTop = false, 22 | enableFullScreen = false, 23 | rotation = null, 24 | ) 25 | 26 | val device2 = 27 | Device.Context( 28 | Device(id = "DEVICE2"), 29 | customName = "CUSTOM_NAME", 30 | enableStayAwake = true, 31 | enableShowTouches = true, 32 | enablePowerOffOnClose = true, 33 | disablePowerOnOnStart = true, 34 | maxSize = 1000, 35 | maxFrameRate = 60, 36 | bitrate = 2, 37 | buffering = 3, 38 | lockOrientation = 1, 39 | noAudio = true, 40 | audioBitrate = 4, 41 | audioBuffering = 5, 42 | enableBorderless = true, 43 | enableAlwaysOnTop = true, 44 | enableFullScreen = true, 45 | rotation = 1, 46 | enableHidKeyboard = true, 47 | enableHidMouse = true, 48 | ) 49 | 50 | "create" { 51 | val factory = ScrcpyCommandCreator(scrcpyBinaryPath = "test${fileSeparator}scrcpy") 52 | factory.create(device1) shouldBe 53 | listOf( 54 | "test${fileSeparator}scrcpy", 55 | "-s", 56 | "DEVICE1", 57 | "--window-title", 58 | "DEVICE1", 59 | ) 60 | factory.create(device2) shouldBe 61 | listOf( 62 | "test${fileSeparator}scrcpy", 63 | "-s", 64 | "DEVICE2", 65 | "--stay-awake", 66 | "--show-touches", 67 | "--power-off-on-close", 68 | "--no-power-on", 69 | "-m", 70 | "1000", 71 | "--max-fps", 72 | "60", 73 | "-b", 74 | "2M", 75 | "--video-buffer=3", 76 | "--no-audio", 77 | "--audio-bit-rate=4K", 78 | "--audio-buffer=5", 79 | "--window-title", 80 | "CUSTOM_NAME", 81 | "--capture-orientation=1", 82 | "--window-borderless", 83 | "--always-on-top", 84 | "--fullscreen", 85 | "--orientation=1", 86 | "--keyboard=uhid", 87 | "--mouse=uhid", 88 | ) 89 | } 90 | "create_when_no_path_specified" { 91 | val factory = ScrcpyCommandCreator() 92 | factory.create(device1) shouldBe listOf("scrcpy", "-s", "DEVICE1", "--window-title", "DEVICE1") 93 | factory.create(device2) shouldBe 94 | listOf( 95 | "scrcpy", 96 | "-s", 97 | "DEVICE2", 98 | "--stay-awake", 99 | "--show-touches", 100 | "--power-off-on-close", 101 | "--no-power-on", 102 | "-m", 103 | "1000", 104 | "--max-fps", 105 | "60", 106 | "-b", 107 | "2M", 108 | "--video-buffer=3", 109 | "--no-audio", 110 | "--audio-bit-rate=4K", 111 | "--audio-buffer=5", 112 | "--window-title", 113 | "CUSTOM_NAME", 114 | "--capture-orientation=1", 115 | "--window-borderless", 116 | "--always-on-top", 117 | "--fullscreen", 118 | "--orientation=1", 119 | "--keyboard=uhid", 120 | "--mouse=uhid", 121 | ) 122 | } 123 | "create_record" { 124 | val factory = ScrcpyCommandCreator(scrcpyBinaryPath = "test${fileSeparator}scrcpy") 125 | factory.createRecord(device1, "fileName1") shouldBe 126 | listOf( 127 | "test${fileSeparator}scrcpy", "-s", "DEVICE1", "--window-title", "DEVICE1", "-r", "fileName1", 128 | ) 129 | factory.createRecord(device2, "fileName2") shouldBe 130 | listOf( 131 | "test${fileSeparator}scrcpy", 132 | "-s", 133 | "DEVICE2", 134 | "--stay-awake", 135 | "--show-touches", 136 | "--power-off-on-close", 137 | "--no-power-on", 138 | "-m", 139 | "1000", 140 | "--max-fps", 141 | "60", 142 | "-b", 143 | "2M", 144 | "--video-buffer=3", 145 | "--no-audio", 146 | "--audio-bit-rate=4K", 147 | "--audio-buffer=5", 148 | "--window-title", 149 | "CUSTOM_NAME", 150 | "--capture-orientation=1", 151 | "--window-borderless", 152 | "--always-on-top", 153 | "--fullscreen", 154 | "--orientation=1", 155 | "--keyboard=uhid", 156 | "--mouse=uhid", 157 | "-r", 158 | "fileName2", 159 | ) 160 | } 161 | "create_record_when_no_path_specified" { 162 | val factory = ScrcpyCommandCreator() 163 | factory.createRecord(device1, "fileName1") shouldBe 164 | listOf( 165 | "scrcpy", "-s", "DEVICE1", "--window-title", "DEVICE1", "-r", "fileName1", 166 | ) 167 | factory.createRecord(device2, "fileName2") shouldBe 168 | listOf( 169 | "scrcpy", 170 | "-s", 171 | "DEVICE2", 172 | "--stay-awake", 173 | "--show-touches", 174 | "--power-off-on-close", 175 | "--no-power-on", 176 | "-m", 177 | "1000", 178 | "--max-fps", 179 | "60", 180 | "-b", 181 | "2M", 182 | "--video-buffer=3", 183 | "--no-audio", 184 | "--audio-bit-rate=4K", 185 | "--audio-buffer=5", 186 | "--window-title", 187 | "CUSTOM_NAME", 188 | "--capture-orientation=1", 189 | "--window-borderless", 190 | "--always-on-top", 191 | "--fullscreen", 192 | "--orientation=1", 193 | "--keyboard=uhid", 194 | "--mouse=uhid", 195 | "-r", 196 | "fileName2", 197 | ) 198 | } 199 | "create_help" { 200 | val factory = ScrcpyCommandCreator(scrcpyBinaryPath = "test${fileSeparator}scrcpy") 201 | factory.createHelp() shouldBe listOf("test${fileSeparator}scrcpy", "-h") 202 | } 203 | "create_help_when_no_path_specified" { 204 | val factory = ScrcpyCommandCreator() 205 | factory.createHelp() shouldBe listOf("scrcpy", "-h") 206 | } 207 | }) 208 | -------------------------------------------------------------------------------- /src/main/kotlin/view/components/AppSetting.kt: -------------------------------------------------------------------------------- 1 | package view.components 2 | 3 | import androidx.compose.desktop.ui.tooling.preview.Preview 4 | import androidx.compose.foundation.VerticalScrollbar 5 | import androidx.compose.foundation.layout.Arrangement 6 | import androidx.compose.foundation.layout.Box 7 | import androidx.compose.foundation.layout.Column 8 | import androidx.compose.foundation.layout.fillMaxHeight 9 | import androidx.compose.foundation.layout.fillMaxSize 10 | import androidx.compose.foundation.layout.fillMaxWidth 11 | import androidx.compose.foundation.layout.padding 12 | import androidx.compose.foundation.rememberScrollState 13 | import androidx.compose.foundation.rememberScrollbarAdapter 14 | import androidx.compose.foundation.verticalScroll 15 | import androidx.compose.material.Card 16 | import androidx.compose.material.MaterialTheme 17 | import androidx.compose.material.Text 18 | import androidx.compose.material.icons.Icons 19 | import androidx.compose.material.icons.filled.FilePresent 20 | import androidx.compose.material.icons.filled.Folder 21 | import androidx.compose.runtime.Composable 22 | import androidx.compose.ui.Alignment 23 | import androidx.compose.ui.Modifier 24 | import androidx.compose.ui.text.font.FontWeight 25 | import androidx.compose.ui.text.style.TextOverflow 26 | import androidx.compose.ui.unit.dp 27 | import io.github.vinceglb.filekit.compose.rememberDirectoryPickerLauncher 28 | import io.github.vinceglb.filekit.compose.rememberFilePickerLauncher 29 | import model.entity.Theme 30 | import view.parts.TextFieldAndError 31 | import view.parts.TitleAndRadioButtons 32 | import view.resource.Strings 33 | 34 | @Composable 35 | fun AppSetting( 36 | theme: Theme, 37 | themes: List, 38 | onUpdateTheme: (Theme) -> Unit, 39 | adbLocation: String, 40 | onUpdateAdbLocation: (String) -> Unit, 41 | scrcpyLocation: String, 42 | onUpdateScrcpyLocation: (String) -> Unit, 43 | screenshotDirectory: String, 44 | onUpdateScreenshotDirectory: (String) -> Unit, 45 | screenRecordDirectory: String, 46 | onUpdateScreenRecordDirectory: (String) -> Unit, 47 | ) { 48 | Box { 49 | val scrollState = rememberScrollState() 50 | val adbFileLauncher = 51 | rememberFilePickerLauncher { file -> 52 | file?.let { onUpdateAdbLocation(it.path ?: "") } 53 | } 54 | val scrcpyFileLauncher = 55 | rememberFilePickerLauncher { file -> 56 | file?.let { onUpdateScrcpyLocation(it.path ?: "") } 57 | } 58 | val screenshotDirectoryLauncher = 59 | rememberDirectoryPickerLauncher { directory -> 60 | directory?.let { onUpdateScreenshotDirectory(it.path ?: "") } 61 | } 62 | val screenRecordDirectoryLauncher = 63 | rememberDirectoryPickerLauncher { directory -> 64 | directory?.let { onUpdateScreenRecordDirectory(it.path ?: "") } 65 | } 66 | 67 | Column( 68 | modifier = 69 | Modifier 70 | .fillMaxSize() 71 | .padding(horizontal = 12.dp) 72 | .padding(vertical = 8.dp) 73 | .verticalScroll(scrollState), 74 | verticalArrangement = Arrangement.spacedBy(8.dp), 75 | ) { 76 | Card(elevation = 4.dp) { 77 | TitleAndRadioButtons( 78 | title = Strings.SETTING_PAGE_EDIT_THEME_TITLE, 79 | selectedItem = theme.toLabel(), 80 | items = themes.map { it.toLabel() }, 81 | onSelect = { label -> onUpdateTheme(themes.first { it.toLabel() == label }) }, 82 | modifier = Modifier.fillMaxWidth().padding(8.dp), 83 | ) 84 | } 85 | 86 | Card(elevation = 4.dp) { 87 | Column { 88 | Text( 89 | text = Strings.SETTING_PAGE_EDIT_BINARY_TITLE, 90 | modifier = Modifier.fillMaxWidth().padding(8.dp), 91 | fontWeight = FontWeight.Bold, 92 | overflow = TextOverflow.Ellipsis, 93 | style = MaterialTheme.typography.subtitle1, 94 | ) 95 | 96 | TextFieldAndError( 97 | label = Strings.SETTING_PAGE_EDIT_ADB_LOCATION_TITLE, 98 | placeHolder = Strings.SETTING_PAGE_EDIT_ADB_LOCATION_DETAILS, 99 | inputText = adbLocation, 100 | onUpdateInputText = { onUpdateAdbLocation(it) }, 101 | trailingIcon = Icons.Default.FilePresent, 102 | onClickTrailingIcon = { adbFileLauncher.launch() }, 103 | modifier = Modifier.padding(8.dp), 104 | ) 105 | 106 | TextFieldAndError( 107 | label = Strings.SETTING_PAGE_EDIT_SCRCPY_LOCATION_TITLE, 108 | placeHolder = Strings.SETTING_PAGE_EDIT_SCRCPY_LOCATION_DETAILS, 109 | inputText = scrcpyLocation, 110 | onUpdateInputText = { onUpdateScrcpyLocation(it) }, 111 | trailingIcon = Icons.Default.FilePresent, 112 | onClickTrailingIcon = { scrcpyFileLauncher.launch() }, 113 | modifier = Modifier.padding(8.dp), 114 | ) 115 | } 116 | } 117 | 118 | Card(elevation = 4.dp) { 119 | Column { 120 | Text( 121 | text = Strings.SETTING_PAGE_EDIT_CAPTURE_AND_RECORD_TITLE, 122 | modifier = Modifier.fillMaxWidth().padding(8.dp), 123 | fontWeight = FontWeight.Bold, 124 | overflow = TextOverflow.Ellipsis, 125 | style = MaterialTheme.typography.subtitle1, 126 | ) 127 | 128 | TextFieldAndError( 129 | label = Strings.SETTING_PAGE_EDIT_SCREEN_SHOT_TITLE, 130 | placeHolder = Strings.SETTING_PAGE_EDIT_SCREEN_SHOT_DETAILS, 131 | inputText = screenshotDirectory, 132 | onUpdateInputText = { onUpdateScreenshotDirectory(it) }, 133 | trailingIcon = Icons.Default.Folder, 134 | onClickTrailingIcon = { screenshotDirectoryLauncher.launch() }, 135 | modifier = Modifier.padding(8.dp), 136 | ) 137 | 138 | TextFieldAndError( 139 | label = Strings.SETTING_PAGE_EDIT_SCREEN_RECORD_TITLE, 140 | placeHolder = Strings.SETTING_PAGE_EDIT_SCREEN_RECORD_DETAILS, 141 | inputText = screenRecordDirectory, 142 | onUpdateInputText = { onUpdateScreenRecordDirectory(it) }, 143 | trailingIcon = Icons.Default.Folder, 144 | onClickTrailingIcon = { screenRecordDirectoryLauncher.launch() }, 145 | modifier = Modifier.padding(8.dp), 146 | ) 147 | } 148 | } 149 | } 150 | 151 | VerticalScrollbar( 152 | adapter = rememberScrollbarAdapter(scrollState), 153 | modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(), 154 | ) 155 | } 156 | } 157 | 158 | private fun Theme.toLabel(): String { 159 | return when (this) { 160 | Theme.LIGHT -> Strings.SETTING_THEME_LIGHT 161 | Theme.DARK -> Strings.SETTING_THEME_DARK 162 | Theme.SYNC_WITH_OS -> Strings.SETTING_THEME_SYNC_WITH_OS 163 | } 164 | } 165 | 166 | @Preview 167 | @Composable 168 | private fun AppSetting_Preview() { 169 | AppSetting( 170 | theme = Theme.LIGHT, 171 | themes = Theme.values().toList(), 172 | onUpdateTheme = {}, 173 | adbLocation = "CUSTOM ADB LOCATION", 174 | onUpdateAdbLocation = {}, 175 | scrcpyLocation = "CUSTOM SCRCPY LOCATION", 176 | onUpdateScrcpyLocation = {}, 177 | screenshotDirectory = "CUSTOM SCREENSHOT DIRECTORY", 178 | onUpdateScreenRecordDirectory = {}, 179 | screenRecordDirectory = "CUSTOM SCREENRECORD DIRECTORY", 180 | onUpdateScreenshotDirectory = {}, 181 | ) 182 | } 183 | -------------------------------------------------------------------------------- /.idea/uiDesigner.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit 84 | 85 | APP_NAME="Gradle" 86 | APP_BASE_NAME=${0##*/} 87 | 88 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 89 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | 142 | # Increase the maximum file descriptors if we can. 143 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 144 | case $MAX_FD in #( 145 | max*) 146 | MAX_FD=$( ulimit -H -n ) || 147 | warn "Could not query maximum file descriptor limit" 148 | esac 149 | case $MAX_FD in #( 150 | '' | soft) :;; #( 151 | *) 152 | ulimit -n "$MAX_FD" || 153 | warn "Could not set maximum file descriptor limit to $MAX_FD" 154 | esac 155 | fi 156 | 157 | # Collect all arguments for the java command, stacking in reverse order: 158 | # * args from the command line 159 | # * the main class name 160 | # * -classpath 161 | # * -D...appname settings 162 | # * --module-path (only if needed) 163 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 164 | 165 | # For Cygwin or MSYS, switch paths to Windows format before running java 166 | if "$cygwin" || "$msys" ; then 167 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 168 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 169 | 170 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 171 | 172 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 173 | for arg do 174 | if 175 | case $arg in #( 176 | -*) false ;; # don't mess with options #( 177 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 178 | [ -e "$t" ] ;; #( 179 | *) false ;; 180 | esac 181 | then 182 | arg=$( cygpath --path --ignore --mixed "$arg" ) 183 | fi 184 | # Roll the args list around exactly as many times as the number of 185 | # args, so each arg winds up back in the position where it started, but 186 | # possibly modified. 187 | # 188 | # NB: a `for` loop captures its iteration list before it begins, so 189 | # changing the positional parameters here affects neither the number of 190 | # iterations, nor the values presented in `arg`. 191 | shift # remove old arg 192 | set -- "$@" "$arg" # push replacement arg 193 | done 194 | fi 195 | 196 | # Collect all arguments for the java command; 197 | # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of 198 | # shell script including quotes and variable substitutions, so put them in 199 | # double quotes to make sure that they get re-expanded; and 200 | # * put everything else in single quotes, so that it's not re-expanded. 201 | 202 | set -- \ 203 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 204 | -classpath "$CLASSPATH" \ 205 | org.gradle.wrapper.GradleWrapperMain \ 206 | "$@" 207 | 208 | # Stop when "xargs" is not available. 209 | if ! command -v xargs >/dev/null 2>&1 210 | then 211 | die "xargs is not available" 212 | fi 213 | 214 | # Use "xargs" to parse quoted args. 215 | # 216 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 217 | # 218 | # In Bash we could simply go: 219 | # 220 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 221 | # set -- "${ARGS[@]}" "$@" 222 | # 223 | # but POSIX shell has neither arrays nor command substitution, so instead we 224 | # post-process each arg (as a line of input to sed) to backslash-escape any 225 | # character that might be a shell metacharacter, then use eval to reverse 226 | # that process (while maintaining the separation between arguments), and wrap 227 | # the whole thing up as a single "set" statement. 228 | # 229 | # This will of course break if any of these variables contains a newline or 230 | # an unmatched quote. 231 | # 232 | 233 | eval "set -- $( 234 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 235 | xargs -n1 | 236 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 237 | tr '\n' ' ' 238 | )" '"$@"' 239 | 240 | exec "$JAVACMD" "$@" 241 | -------------------------------------------------------------------------------- /src/main/kotlin/view/components/DeviceCard.kt: -------------------------------------------------------------------------------- 1 | @file:JvmName("DeviceCardKt") 2 | 3 | package view.components 4 | 5 | import androidx.compose.desktop.ui.tooling.preview.Preview 6 | import androidx.compose.foundation.clickable 7 | import androidx.compose.foundation.indication 8 | import androidx.compose.foundation.interaction.MutableInteractionSource 9 | import androidx.compose.foundation.layout.Arrangement 10 | import androidx.compose.foundation.layout.Column 11 | import androidx.compose.foundation.layout.Row 12 | import androidx.compose.foundation.layout.height 13 | import androidx.compose.foundation.layout.padding 14 | import androidx.compose.foundation.layout.size 15 | import androidx.compose.foundation.layout.wrapContentWidth 16 | import androidx.compose.foundation.shape.CircleShape 17 | import androidx.compose.material.Card 18 | import androidx.compose.material.Icon 19 | import androidx.compose.material.MaterialTheme 20 | import androidx.compose.material.Text 21 | import androidx.compose.material.icons.Icons 22 | import androidx.compose.material.icons.filled.ModeEdit 23 | import androidx.compose.material.icons.filled.PhotoCamera 24 | import androidx.compose.material.ripple.rememberRipple 25 | import androidx.compose.runtime.Composable 26 | import androidx.compose.runtime.LaunchedEffect 27 | import androidx.compose.runtime.mutableStateOf 28 | import androidx.compose.runtime.remember 29 | import androidx.compose.ui.Alignment 30 | import androidx.compose.ui.Modifier 31 | import androidx.compose.ui.draw.clip 32 | import androidx.compose.ui.text.font.FontWeight 33 | import androidx.compose.ui.text.style.TextOverflow 34 | import androidx.compose.ui.unit.dp 35 | import kotlinx.coroutines.coroutineScope 36 | import kotlinx.coroutines.delay 37 | import model.entity.Device 38 | import model.repository.ProcessStatus 39 | import view.common.ElapsedTimeCalculator 40 | import view.pages.devices.DeviceStatus 41 | import view.resource.MainTheme 42 | import java.util.Date 43 | 44 | @Composable 45 | fun DeviceCard( 46 | deviceStatus: DeviceStatus, 47 | enableGoToDetail: Boolean, 48 | startScrcpy: ((Device.Context) -> Unit), 49 | stopScrcpy: ((Device.Context) -> Unit), 50 | goToDetail: ((Device.Context) -> Unit), 51 | takeScreenshot: ((Device.Context) -> Unit), 52 | startRecording: ((Device.Context) -> Unit), 53 | stopRecording: ((Device.Context) -> Unit), 54 | modifier: Modifier = Modifier, 55 | ) { 56 | val (currentTime, setCurrentTime) = remember { mutableStateOf("") } 57 | LaunchedEffect(deviceStatus.processStatus) { 58 | when (val processStatus = deviceStatus.processStatus) { 59 | is ProcessStatus.Recording -> { 60 | coroutineScope { 61 | while (true) { 62 | val elapsedTime = ElapsedTimeCalculator.calc(processStatus.startDate, Date()) 63 | setCurrentTime(elapsedTime) 64 | delay(1000) 65 | } 66 | } 67 | } 68 | 69 | is ProcessStatus.Running -> { 70 | coroutineScope { 71 | while (true) { 72 | val elapsedTime = ElapsedTimeCalculator.calc(processStatus.startDate, Date()) 73 | setCurrentTime(elapsedTime) 74 | delay(1000) 75 | } 76 | } 77 | } 78 | 79 | else -> { 80 | setCurrentTime("") 81 | } 82 | } 83 | } 84 | 85 | Card(elevation = 4.dp, modifier = modifier) { 86 | Column( 87 | verticalArrangement = Arrangement.spacedBy(12.dp), 88 | modifier = Modifier.padding(12.dp), 89 | ) { 90 | Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { 91 | Text( 92 | text = deviceStatus.context.displayName, 93 | maxLines = 1, 94 | overflow = TextOverflow.Ellipsis, 95 | fontWeight = FontWeight.Bold, 96 | style = MaterialTheme.typography.subtitle1, 97 | modifier = Modifier.weight(1.0f, true).align(Alignment.CenterVertically).height(24.dp), 98 | ) 99 | 100 | Text( 101 | text = currentTime, 102 | maxLines = 1, 103 | overflow = TextOverflow.Ellipsis, 104 | fontWeight = FontWeight.Bold, 105 | style = MaterialTheme.typography.subtitle1, 106 | modifier = Modifier.wrapContentWidth().align(Alignment.CenterVertically), 107 | ) 108 | 109 | Icon( 110 | imageVector = Icons.Default.PhotoCamera, 111 | contentDescription = "Screenshot", 112 | modifier = 113 | Modifier 114 | .size(30.dp) 115 | .clip(CircleShape) 116 | .indication(MutableInteractionSource(), rememberRipple()) 117 | .clickable { takeScreenshot.invoke(deviceStatus.context) } 118 | .padding(4.dp) 119 | .align(Alignment.CenterVertically), 120 | ) 121 | 122 | if (enableGoToDetail) { 123 | Icon( 124 | imageVector = Icons.Default.ModeEdit, 125 | contentDescription = "ModeEdit", 126 | modifier = 127 | Modifier 128 | .size(30.dp) 129 | .clip(CircleShape) 130 | .indication(MutableInteractionSource(), rememberRipple()) 131 | .clickable { goToDetail.invoke(deviceStatus.context) } 132 | .padding(4.dp) 133 | .align(Alignment.CenterVertically), 134 | ) 135 | } 136 | } 137 | 138 | ScrcpyButtons( 139 | deviceStatus = deviceStatus, 140 | startScrcpy = startScrcpy, 141 | stopScrcpy = stopScrcpy, 142 | startRecording = startRecording, 143 | stopRecording = stopRecording, 144 | ) 145 | } 146 | } 147 | } 148 | 149 | @Preview 150 | @Composable 151 | private fun DeviceCard_Preview_DARK() { 152 | val device = Device("00001") 153 | val context1 = Device.Context(device) 154 | val context2 = Device.Context(device, "CUSTOM_NAME1") 155 | val context3 = Device.Context(device, "CUSTOM_NAME2") 156 | 157 | MainTheme(isDarkMode = true) { 158 | Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { 159 | DeviceCard( 160 | deviceStatus = DeviceStatus(context1, ProcessStatus.Idle), 161 | enableGoToDetail = false, 162 | startScrcpy = {}, 163 | stopScrcpy = {}, 164 | goToDetail = {}, 165 | takeScreenshot = {}, 166 | startRecording = {}, 167 | stopRecording = {}, 168 | modifier = Modifier, 169 | ) 170 | 171 | DeviceCard( 172 | deviceStatus = DeviceStatus(context2, ProcessStatus.Running()), 173 | enableGoToDetail = true, 174 | startScrcpy = {}, 175 | stopScrcpy = {}, 176 | goToDetail = {}, 177 | takeScreenshot = {}, 178 | startRecording = {}, 179 | stopRecording = {}, 180 | modifier = Modifier, 181 | ) 182 | 183 | DeviceCard( 184 | deviceStatus = DeviceStatus(context3, ProcessStatus.Recording()), 185 | enableGoToDetail = false, 186 | startScrcpy = {}, 187 | stopScrcpy = {}, 188 | goToDetail = {}, 189 | takeScreenshot = {}, 190 | startRecording = {}, 191 | stopRecording = {}, 192 | modifier = Modifier, 193 | ) 194 | } 195 | } 196 | } 197 | 198 | @Preview 199 | @Composable 200 | private fun DeviceCard_Preview_Light() { 201 | val device = Device("00001") 202 | val context1 = Device.Context(device) 203 | val context2 = Device.Context(device, "CUSTOM_NAME1") 204 | val context3 = Device.Context(device, "CUSTOM_NAME2") 205 | 206 | MainTheme(isDarkMode = false) { 207 | Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { 208 | DeviceCard( 209 | deviceStatus = DeviceStatus(context1, ProcessStatus.Idle), 210 | enableGoToDetail = false, 211 | startScrcpy = {}, 212 | stopScrcpy = {}, 213 | goToDetail = {}, 214 | takeScreenshot = {}, 215 | startRecording = {}, 216 | stopRecording = {}, 217 | modifier = Modifier, 218 | ) 219 | 220 | DeviceCard( 221 | deviceStatus = DeviceStatus(context2, ProcessStatus.Running()), 222 | enableGoToDetail = true, 223 | startScrcpy = {}, 224 | stopScrcpy = {}, 225 | goToDetail = {}, 226 | takeScreenshot = {}, 227 | startRecording = {}, 228 | stopRecording = {}, 229 | modifier = Modifier, 230 | ) 231 | 232 | DeviceCard( 233 | deviceStatus = DeviceStatus(context3, ProcessStatus.Recording()), 234 | enableGoToDetail = false, 235 | startScrcpy = {}, 236 | stopScrcpy = {}, 237 | goToDetail = {}, 238 | takeScreenshot = {}, 239 | startRecording = {}, 240 | stopRecording = {}, 241 | modifier = Modifier, 242 | ) 243 | } 244 | } 245 | } 246 | --------------------------------------------------------------------------------