├── .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 |
4 |
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 |
5 |
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 |
--------------------------------------------------------------------------------
/src/main/resources/device.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/main/resources/setting.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/main/resources/dots.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 |
9 |
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 |
15 |
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 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/.idea/gradle.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
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 |
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 |
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 |
--------------------------------------------------------------------------------
/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 | 
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 | |  |  |
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 |
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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------