├── .editorconfig ├── .github └── workflows │ └── build.yml ├── .gitignore ├── .idea ├── codeStyles │ ├── Project.xml │ └── codeStyleConfig.xml └── icon.svg ├── LICENSE ├── README.md ├── app ├── build.gradle.kts ├── proguard-rules.pro ├── schemas │ └── net.simno.dmach.db.PatchDatabase │ │ ├── 3.json │ │ ├── 4.json │ │ └── 5.json └── src │ ├── androidTest │ └── java │ │ └── net │ │ └── simno │ │ └── dmach │ │ └── db │ │ ├── DbTests.kt │ │ └── PatchTable.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── net │ │ │ └── simno │ │ │ └── dmach │ │ │ ├── App.kt │ │ │ ├── Destination.kt │ │ │ ├── MainActivity.kt │ │ │ ├── StateViewModel.kt │ │ │ ├── core │ │ │ ├── CoreDialog.kt │ │ │ ├── DarkButton.kt │ │ │ ├── HapticClick.kt │ │ │ ├── LightButton.kt │ │ │ ├── OptionsDialog.kt │ │ │ ├── PredictiveBackProgress.kt │ │ │ └── Text.kt │ │ │ ├── data │ │ │ ├── Channel.kt │ │ │ ├── DefaultPatch.kt │ │ │ ├── Pan.kt │ │ │ ├── Patch.kt │ │ │ ├── Position.kt │ │ │ ├── Setting.kt │ │ │ ├── Steps.kt │ │ │ ├── Swing.kt │ │ │ └── Tempo.kt │ │ │ ├── db │ │ │ ├── DbModule.kt │ │ │ ├── PatchDao.kt │ │ │ ├── PatchDatabase.kt │ │ │ ├── PatchEntity.kt │ │ │ └── PatchRepository.kt │ │ │ ├── machine │ │ │ ├── MachineModule.kt │ │ │ ├── MachineScreen.kt │ │ │ ├── MachineViewModel.kt │ │ │ ├── state │ │ │ │ ├── Action.kt │ │ │ │ ├── MachineProcessor.kt │ │ │ │ ├── MachineStateReducer.kt │ │ │ │ ├── Randomizer.kt │ │ │ │ ├── Result.kt │ │ │ │ └── ViewState.kt │ │ │ └── ui │ │ │ │ ├── ChaosPad.kt │ │ │ │ ├── ConfigCheckbox.kt │ │ │ │ ├── ConfigDialog.kt │ │ │ │ ├── ConfigValue.kt │ │ │ │ ├── ExportDialog.kt │ │ │ │ ├── IconButton.kt │ │ │ │ ├── Machine.kt │ │ │ │ ├── PanFader.kt │ │ │ │ ├── StepSequencer.kt │ │ │ │ └── TextButton.kt │ │ │ ├── patch │ │ │ ├── PatchModule.kt │ │ │ ├── PatchScreen.kt │ │ │ ├── PatchViewModel.kt │ │ │ ├── state │ │ │ │ ├── Action.kt │ │ │ │ ├── PatchProcessor.kt │ │ │ │ ├── PatchStateReducer.kt │ │ │ │ ├── Result.kt │ │ │ │ └── ViewState.kt │ │ │ └── ui │ │ │ │ └── Patch.kt │ │ │ ├── playback │ │ │ ├── AudioFocus.kt │ │ │ ├── PlaybackController.kt │ │ │ ├── PlaybackModule.kt │ │ │ ├── PlaybackService.kt │ │ │ ├── PureData.kt │ │ │ └── WaveExporter.kt │ │ │ ├── settings │ │ │ ├── Settings.kt │ │ │ ├── SettingsModule.kt │ │ │ └── SettingsRepository.kt │ │ │ ├── theme │ │ │ ├── Color.kt │ │ │ ├── Dimension.kt │ │ │ ├── Shape.kt │ │ │ ├── Theme.kt │ │ │ └── Type.kt │ │ │ └── util │ │ │ ├── DpUtils.kt │ │ │ └── Logging.kt │ └── res │ │ ├── drawable │ │ ├── ic_launcher_foreground.xml │ │ └── ic_stat_playback.xml │ │ ├── mipmap │ │ └── ic_launcher.xml │ │ ├── raw │ │ └── dmach.zip │ │ ├── values │ │ ├── ic_launcher_background.xml │ │ ├── strings.xml │ │ └── styles.xml │ │ └── xml │ │ └── file_provider_paths.xml │ └── test │ └── java │ └── net │ └── simno │ └── dmach │ ├── db │ └── TestPatchDao.kt │ ├── machine │ └── state │ │ └── MachineProcessorTests.kt │ ├── patch │ └── state │ │ └── PatchProcessorTests.kt │ └── playback │ └── AudioFocusTests.kt ├── build.gradle.kts ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── lint.xml ├── screenshots ├── dmach-1.webp ├── dmach-2.webp ├── dmach-3.webp └── dmach-4.webp └── settings.gradle.kts /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.{json,yml}] 12 | indent_size = 2 13 | 14 | [*.{kt,kts}] 15 | ktlint_code_style = android_studio 16 | max_line_length = 120 17 | compose_allowed_composition_locals = LocalDimensions 18 | ktlint_function_naming_ignore_when_annotated_with = Composable 19 | ktlint_standard_function-signature = disabled 20 | ij_kotlin_line_break_after_multiline_when_entry = false 21 | ktlint_class_signature_rule_force_multiline_when_parameter_count_greater_or_equal_than = 1 -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Android CI 2 | 3 | on: [ push ] 4 | 5 | concurrency: 6 | group: build-${{ github.ref }} 7 | cancel-in-progress: true 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | if: github.repository == 'simonnorberg/dmach' 13 | timeout-minutes: 60 14 | 15 | strategy: 16 | matrix: 17 | api-level: [ 28, 34 ] 18 | 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v4.2.2 22 | 23 | - name: Set up JDK 24 | uses: actions/setup-java@v4.7.1 25 | with: 26 | distribution: 'zulu' 27 | java-version: 21 28 | 29 | - name: Enable KVM 30 | run: | 31 | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules 32 | sudo udevadm control --reload-rules 33 | sudo udevadm trigger --name-match=kvm 34 | 35 | - name: Build and check 36 | run: ./gradlew ktlintCheck assemble bundle check 37 | 38 | - name: Run tests 39 | uses: reactivecircus/android-emulator-runner@v2.34.0 40 | with: 41 | api-level: ${{ matrix.api-level }} 42 | arch: x86_64 43 | script: ./gradlew connectedCheck 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | local.properties 4 | .idea/* 5 | !.idea/codeStyles/ 6 | !.idea/icon.svg 7 | .DS_Store 8 | build 9 | captures 10 | .externalNativeBuild 11 | .cxx 12 | .kotlin 13 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | 11 | 14 | 15 | 48 | 49 | 50 | 56 | 59 | 60 | 172 | 173 | 179 | 180 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DMach [![Android CI](https://github.com/simonnorberg/dmach/workflows/Android%20CI/badge.svg)](https://github.com/simonnorberg/dmach/actions) 2 | 3 | DMach is a drum machine for Android with 6 channels, a 16 step sequencer and real-time sound synthesis. 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.android.application) 3 | alias(libs.plugins.cachefix) 4 | alias(libs.plugins.hilt.android) 5 | alias(libs.plugins.kotlin.android) 6 | alias(libs.plugins.kotlin.compose) 7 | alias(libs.plugins.kotlin.serialization) 8 | alias(libs.plugins.ksp) 9 | } 10 | 11 | hilt { 12 | enableAggregatingTask = true 13 | } 14 | 15 | ksp { 16 | arg("room.schemaLocation", "$projectDir/schemas") 17 | arg("room.incremental", "true") 18 | arg("room.generateKotlin", "true") 19 | } 20 | 21 | android { 22 | namespace = "net.simno.dmach" 23 | compileSdk = libs.versions.compileSdk.get().toInt() 24 | ndkVersion = libs.versions.ndk.get() 25 | defaultConfig { 26 | applicationId = "net.simno.dmach" 27 | minSdk = libs.versions.minSdk.get().toInt() 28 | targetSdk = libs.versions.targetSdk.get().toInt() 29 | versionCode = 30013 30 | versionName = "3.8" 31 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 32 | ndk.abiFilters.addAll(listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")) 33 | } 34 | buildTypes { 35 | release { 36 | isMinifyEnabled = true 37 | isShrinkResources = true 38 | proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") 39 | } 40 | } 41 | buildFeatures { 42 | buildConfig = true 43 | compose = true 44 | } 45 | packaging { 46 | resources.excludes += "/META-INF/{AL2.0,LGPL2.1}" 47 | jniLibs.useLegacyPackaging = true 48 | } 49 | testOptions { 50 | unitTests.isIncludeAndroidResources = true 51 | } 52 | sourceSets.getByName("androidTest") { 53 | assets.srcDirs("$projectDir/schemas", "$projectDir/databases") 54 | } 55 | lint { 56 | warningsAsErrors = true 57 | abortOnError = true 58 | } 59 | } 60 | 61 | dependencies { 62 | implementation(platform(libs.coroutines.bom)) 63 | implementation(libs.coroutines.android) 64 | implementation(libs.coroutines.core) 65 | implementation(libs.kotlin.collections) 66 | implementation(libs.kotlin.serialization) 67 | 68 | implementation(platform(libs.compose.bom)) 69 | implementation(libs.compose.material) 70 | implementation(libs.compose.material.icons) 71 | implementation(libs.compose.ui) 72 | implementation(libs.compose.ui.tooling.preview) 73 | debugImplementation(libs.compose.ui.tooling) 74 | debugImplementation(libs.compose.ui.testmanifest) 75 | lintChecks(libs.compose.lint) 76 | ktlintRuleset(libs.ktlint.compose) 77 | 78 | implementation(libs.androidx.activity) 79 | implementation(libs.androidx.core) 80 | implementation(libs.androidx.datastore) 81 | ksp(libs.androidx.hilt.compiler) 82 | implementation(libs.androidx.hilt.navigation) 83 | implementation(libs.androidx.media) 84 | implementation(libs.androidx.navigation) 85 | 86 | implementation(libs.androidx.lifecycle.runtime.compose) 87 | implementation(libs.androidx.lifecycle.runtime.ktx) 88 | implementation(libs.androidx.lifecycle.viewmodel.compose) 89 | implementation(libs.androidx.lifecycle.viewmodel.ktx) 90 | 91 | implementation(libs.androidx.paging.common) 92 | implementation(libs.androidx.paging.runtime) 93 | implementation(libs.androidx.paging.compose) 94 | 95 | implementation(libs.androidx.room.runtime) 96 | implementation(libs.androidx.room.ktx) 97 | implementation(libs.androidx.room.paging) 98 | ksp(libs.androidx.room.compiler) 99 | 100 | implementation(libs.hilt.android) 101 | ksp(libs.hilt.compiler) 102 | 103 | implementation(libs.dmach.externals) 104 | implementation(libs.kortholt) 105 | 106 | implementation(libs.leakcanary.plumber) 107 | 108 | androidTestImplementation(libs.androidx.room.testing) 109 | androidTestImplementation(libs.androidx.test.espresso) 110 | androidTestImplementation(libs.androidx.test.junit) 111 | androidTestImplementation(libs.androidx.test.truth) 112 | 113 | testImplementation(libs.androidx.paging.common) 114 | testImplementation(libs.androidx.test.core) 115 | testImplementation(libs.androidx.test.runner) 116 | testImplementation(libs.androidx.test.junit) 117 | testImplementation(libs.androidx.test.truth) 118 | testImplementation(libs.mockito.core) 119 | testImplementation(libs.mockito.kotlin) 120 | testImplementation(libs.robolectric) 121 | } 122 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Kotlin 2 | -keep class kotlin.** { *; } 3 | -keep class kotlin.Metadata { *; } 4 | -dontwarn kotlin.** 5 | -keepclassmembers class **$WhenMappings { 6 | ; 7 | } 8 | -keepclassmembers class kotlin.Metadata { 9 | public ; 10 | } 11 | -assumenosideeffects class kotlin.jvm.internal.Intrinsics { 12 | static void checkParameterIsNotNull(java.lang.Object, java.lang.String); 13 | } 14 | 15 | 16 | # Coroutines 17 | # https://github.com/Kotlin/kotlinx.coroutines/blob/master/kotlinx-coroutines-core/jvm/resources/META-INF/proguard/coroutines.pro 18 | # ServiceLoader support 19 | -keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {} 20 | -keepnames class kotlinx.coroutines.CoroutineExceptionHandler {} 21 | 22 | # Most of volatile fields are updated with AFU and should not be mangled 23 | -keepclassmembers class kotlinx.coroutines.** { 24 | volatile ; 25 | } 26 | 27 | # Same story for the standard library's SafeContinuation that also uses AtomicReferenceFieldUpdater 28 | -keepclassmembers class kotlin.coroutines.SafeContinuation { 29 | volatile ; 30 | } 31 | 32 | 33 | # Kotlin serialization 34 | # https://github.com/Kotlin/kotlinx.serialization#android 35 | # Keep `Companion` object fields of serializable classes. 36 | # This avoids serializer lookup through `getDeclaredClasses` as done for named companion objects. 37 | -if @kotlinx.serialization.Serializable class ** 38 | -keepclassmembers class <1> { 39 | static <1>$Companion Companion; 40 | } 41 | 42 | # Keep `serializer()` on companion objects (both default and named) of serializable classes. 43 | -if @kotlinx.serialization.Serializable class ** { 44 | static **$* *; 45 | } 46 | -keepclassmembers class <2>$<3> { 47 | kotlinx.serialization.KSerializer serializer(...); 48 | } 49 | 50 | # Keep `INSTANCE.serializer()` of serializable objects. 51 | -if @kotlinx.serialization.Serializable class ** { 52 | public static ** INSTANCE; 53 | } 54 | -keepclassmembers class <1> { 55 | public static <1> INSTANCE; 56 | kotlinx.serialization.KSerializer serializer(...); 57 | } 58 | 59 | # @Serializable and @Polymorphic are used at runtime for polymorphic serialization. 60 | -keepattributes RuntimeVisibleAnnotations,AnnotationDefault 61 | -------------------------------------------------------------------------------- /app/schemas/net.simno.dmach.db.PatchDatabase/3.json: -------------------------------------------------------------------------------- 1 | { 2 | "formatVersion": 1, 3 | "database": { 4 | "version": 3, 5 | "identityHash": "c2783958f9f3a53874acc7a4cc74fdda", 6 | "entities": [ 7 | { 8 | "tableName": "patch", 9 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `title` TEXT NOT NULL, `sequence` TEXT NOT NULL, `channels` TEXT NOT NULL, `selected` INTEGER NOT NULL, `tempo` INTEGER NOT NULL, `swing` INTEGER NOT NULL, `active` INTEGER NOT NULL)", 10 | "fields": [ 11 | { 12 | "fieldPath": "_id", 13 | "columnName": "_id", 14 | "affinity": "INTEGER", 15 | "notNull": false 16 | }, 17 | { 18 | "fieldPath": "title", 19 | "columnName": "title", 20 | "affinity": "TEXT", 21 | "notNull": true 22 | }, 23 | { 24 | "fieldPath": "sequence", 25 | "columnName": "sequence", 26 | "affinity": "TEXT", 27 | "notNull": true 28 | }, 29 | { 30 | "fieldPath": "channels", 31 | "columnName": "channels", 32 | "affinity": "TEXT", 33 | "notNull": true 34 | }, 35 | { 36 | "fieldPath": "selected", 37 | "columnName": "selected", 38 | "affinity": "INTEGER", 39 | "notNull": true 40 | }, 41 | { 42 | "fieldPath": "tempo", 43 | "columnName": "tempo", 44 | "affinity": "INTEGER", 45 | "notNull": true 46 | }, 47 | { 48 | "fieldPath": "swing", 49 | "columnName": "swing", 50 | "affinity": "INTEGER", 51 | "notNull": true 52 | }, 53 | { 54 | "fieldPath": "active", 55 | "columnName": "active", 56 | "affinity": "INTEGER", 57 | "notNull": true 58 | } 59 | ], 60 | "primaryKey": { 61 | "columnNames": [ 62 | "_id" 63 | ], 64 | "autoGenerate": true 65 | }, 66 | "indices": [ 67 | { 68 | "name": "index_patch_title", 69 | "unique": true, 70 | "columnNames": [ 71 | "title" 72 | ], 73 | "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_patch_title` ON `${TABLE_NAME}` (`title`)" 74 | } 75 | ], 76 | "foreignKeys": [] 77 | } 78 | ], 79 | "views": [], 80 | "setupQueries": [ 81 | "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", 82 | "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c2783958f9f3a53874acc7a4cc74fdda')" 83 | ] 84 | } 85 | } -------------------------------------------------------------------------------- /app/schemas/net.simno.dmach.db.PatchDatabase/4.json: -------------------------------------------------------------------------------- 1 | { 2 | "formatVersion": 1, 3 | "database": { 4 | "version": 4, 5 | "identityHash": "91d024659f34ecd633b46f9f4a9cc1e6", 6 | "entities": [ 7 | { 8 | "tableName": "patch", 9 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `title` TEXT NOT NULL, `sequence` TEXT NOT NULL, `channels` TEXT NOT NULL, `selected` INTEGER NOT NULL, `tempo` INTEGER NOT NULL, `swing` INTEGER NOT NULL, `steps` INTEGER NOT NULL, `active` INTEGER NOT NULL)", 10 | "fields": [ 11 | { 12 | "fieldPath": "_id", 13 | "columnName": "_id", 14 | "affinity": "INTEGER", 15 | "notNull": false 16 | }, 17 | { 18 | "fieldPath": "title", 19 | "columnName": "title", 20 | "affinity": "TEXT", 21 | "notNull": true 22 | }, 23 | { 24 | "fieldPath": "sequence", 25 | "columnName": "sequence", 26 | "affinity": "TEXT", 27 | "notNull": true 28 | }, 29 | { 30 | "fieldPath": "channels", 31 | "columnName": "channels", 32 | "affinity": "TEXT", 33 | "notNull": true 34 | }, 35 | { 36 | "fieldPath": "selected", 37 | "columnName": "selected", 38 | "affinity": "INTEGER", 39 | "notNull": true 40 | }, 41 | { 42 | "fieldPath": "tempo", 43 | "columnName": "tempo", 44 | "affinity": "INTEGER", 45 | "notNull": true 46 | }, 47 | { 48 | "fieldPath": "swing", 49 | "columnName": "swing", 50 | "affinity": "INTEGER", 51 | "notNull": true 52 | }, 53 | { 54 | "fieldPath": "steps", 55 | "columnName": "steps", 56 | "affinity": "INTEGER", 57 | "notNull": true 58 | }, 59 | { 60 | "fieldPath": "active", 61 | "columnName": "active", 62 | "affinity": "INTEGER", 63 | "notNull": true 64 | } 65 | ], 66 | "primaryKey": { 67 | "autoGenerate": true, 68 | "columnNames": [ 69 | "_id" 70 | ] 71 | }, 72 | "indices": [ 73 | { 74 | "name": "index_patch_title", 75 | "unique": true, 76 | "columnNames": [ 77 | "title" 78 | ], 79 | "orders": [], 80 | "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_patch_title` ON `${TABLE_NAME}` (`title`)" 81 | } 82 | ], 83 | "foreignKeys": [] 84 | } 85 | ], 86 | "views": [], 87 | "setupQueries": [ 88 | "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", 89 | "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '91d024659f34ecd633b46f9f4a9cc1e6')" 90 | ] 91 | } 92 | } -------------------------------------------------------------------------------- /app/schemas/net.simno.dmach.db.PatchDatabase/5.json: -------------------------------------------------------------------------------- 1 | { 2 | "formatVersion": 1, 3 | "database": { 4 | "version": 5, 5 | "identityHash": "d7464484e9457b6c5837b91fc673c599", 6 | "entities": [ 7 | { 8 | "tableName": "patch", 9 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `title` TEXT NOT NULL, `sequence` TEXT NOT NULL, `muted` TEXT NOT NULL, `channels` TEXT NOT NULL, `selected` INTEGER NOT NULL, `tempo` INTEGER NOT NULL, `swing` INTEGER NOT NULL, `steps` INTEGER NOT NULL, `active` INTEGER NOT NULL)", 10 | "fields": [ 11 | { 12 | "fieldPath": "id", 13 | "columnName": "_id", 14 | "affinity": "INTEGER", 15 | "notNull": false 16 | }, 17 | { 18 | "fieldPath": "title", 19 | "columnName": "title", 20 | "affinity": "TEXT", 21 | "notNull": true 22 | }, 23 | { 24 | "fieldPath": "sequence", 25 | "columnName": "sequence", 26 | "affinity": "TEXT", 27 | "notNull": true 28 | }, 29 | { 30 | "fieldPath": "muted", 31 | "columnName": "muted", 32 | "affinity": "TEXT", 33 | "notNull": true 34 | }, 35 | { 36 | "fieldPath": "channels", 37 | "columnName": "channels", 38 | "affinity": "TEXT", 39 | "notNull": true 40 | }, 41 | { 42 | "fieldPath": "selected", 43 | "columnName": "selected", 44 | "affinity": "INTEGER", 45 | "notNull": true 46 | }, 47 | { 48 | "fieldPath": "tempo", 49 | "columnName": "tempo", 50 | "affinity": "INTEGER", 51 | "notNull": true 52 | }, 53 | { 54 | "fieldPath": "swing", 55 | "columnName": "swing", 56 | "affinity": "INTEGER", 57 | "notNull": true 58 | }, 59 | { 60 | "fieldPath": "steps", 61 | "columnName": "steps", 62 | "affinity": "INTEGER", 63 | "notNull": true 64 | }, 65 | { 66 | "fieldPath": "active", 67 | "columnName": "active", 68 | "affinity": "INTEGER", 69 | "notNull": true 70 | } 71 | ], 72 | "primaryKey": { 73 | "autoGenerate": true, 74 | "columnNames": [ 75 | "_id" 76 | ] 77 | }, 78 | "indices": [ 79 | { 80 | "name": "index_patch_title", 81 | "unique": true, 82 | "columnNames": [ 83 | "title" 84 | ], 85 | "orders": [], 86 | "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_patch_title` ON `${TABLE_NAME}` (`title`)" 87 | } 88 | ], 89 | "foreignKeys": [] 90 | } 91 | ], 92 | "views": [], 93 | "setupQueries": [ 94 | "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", 95 | "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd7464484e9457b6c5837b91fc673c599')" 96 | ] 97 | } 98 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/net/simno/dmach/db/DbTests.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.db 2 | 3 | import android.content.Context 4 | import android.database.sqlite.SQLiteDatabase 5 | import androidx.core.content.contentValuesOf 6 | import androidx.room.testing.MigrationTestHelper 7 | import androidx.sqlite.db.SupportSQLiteDatabase 8 | import androidx.sqlite.db.SupportSQLiteOpenHelper 9 | import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory 10 | import androidx.test.core.app.ApplicationProvider 11 | import androidx.test.ext.junit.runners.AndroidJUnit4 12 | import androidx.test.platform.app.InstrumentationRegistry 13 | import com.google.common.truth.Truth.assertThat 14 | import kotlinx.coroutines.flow.first 15 | import kotlinx.coroutines.runBlocking 16 | import net.simno.dmach.data.defaultPatch 17 | import net.simno.dmach.db.PatchRepository.Companion.toEntity 18 | import org.junit.After 19 | import org.junit.Before 20 | import org.junit.Rule 21 | import org.junit.Test 22 | import org.junit.runner.RunWith 23 | 24 | @RunWith(AndroidJUnit4::class) 25 | class DbTests { 26 | 27 | @Rule 28 | @JvmField 29 | val migrationTestHelper = MigrationTestHelper( 30 | InstrumentationRegistry.getInstrumentation(), 31 | PatchDatabase::class.java, 32 | emptyList(), 33 | FrameworkSQLiteOpenHelperFactory() 34 | ) 35 | 36 | @Before 37 | fun setup() { 38 | deleteDatabase() 39 | } 40 | 41 | @After 42 | fun tearDown() { 43 | deleteDatabase() 44 | } 45 | 46 | private fun deleteDatabase() { 47 | val context = ApplicationProvider.getApplicationContext() as Context 48 | context.deleteDatabase(PatchDatabase.NAME) 49 | } 50 | 51 | private fun createSqliteOpenHelper(): SupportSQLiteOpenHelper { 52 | val callback = object : SupportSQLiteOpenHelper.Callback(PatchTable.VERSION) { 53 | override fun onCreate(db: SupportSQLiteDatabase) { 54 | db.execSQL(PatchTable.CREATE_TABLE) 55 | } 56 | 57 | override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) { 58 | } 59 | 60 | override fun onDowngrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) { 61 | } 62 | } 63 | val configuration = SupportSQLiteOpenHelper.Configuration 64 | .builder(ApplicationProvider.getApplicationContext()) 65 | .name(PatchDatabase.NAME) 66 | .callback(callback) 67 | .build() 68 | return FrameworkSQLiteOpenHelperFactory().create(configuration) 69 | } 70 | 71 | @Test 72 | fun dbMigration() { 73 | val sqliteOpenHelper = createSqliteOpenHelper() 74 | 75 | val patch = defaultPatch() 76 | val entity = runBlocking { patch.toEntity(patch.title) } 77 | 78 | val values = contentValuesOf( 79 | PatchTable.TITLE to entity.title, 80 | PatchTable.SEQUENCE to entity.sequence, 81 | PatchTable.CHANNELS to entity.channels, 82 | PatchTable.SELECTED to entity.selected, 83 | PatchTable.TEMPO to entity.tempo, 84 | PatchTable.SWING to entity.swing 85 | ) 86 | 87 | val db = sqliteOpenHelper.writableDatabase 88 | db.insert(PatchTable.TABLE_NAME, SQLiteDatabase.CONFLICT_REPLACE, values) 89 | db.close() 90 | 91 | migrationTestHelper.runMigrationsAndValidate( 92 | PatchDatabase.NAME, 93 | 5, 94 | true, 95 | PatchDatabase.MIGRATION_2_3, 96 | PatchDatabase.MIGRATION_3_4, 97 | PatchDatabase.MIGRATION_4_5 98 | ) 99 | 100 | val patchDatabase = DbModule.providePatchDatabase(ApplicationProvider.getApplicationContext()) 101 | migrationTestHelper.closeWhenFinished(patchDatabase) 102 | 103 | val migratedPatch = runBlocking { PatchRepository(patchDatabase.patchDao()).activePatch().first() } 104 | assertThat(migratedPatch).isEqualTo(patch) 105 | } 106 | 107 | @Test 108 | fun dbDefaultPatch() { 109 | val patchDatabase = DbModule.providePatchDatabase(ApplicationProvider.getApplicationContext()) 110 | migrationTestHelper.closeWhenFinished(patchDatabase) 111 | 112 | val defaultPatch = runBlocking { PatchRepository(patchDatabase.patchDao()).activePatch().first() } 113 | assertThat(defaultPatch).isEqualTo(defaultPatch()) 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /app/src/androidTest/java/net/simno/dmach/db/PatchTable.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.db 2 | 3 | object PatchTable { 4 | const val VERSION = 2 5 | const val TABLE_NAME = "patch" 6 | private const val ID = "_id" 7 | const val TITLE = "title" 8 | const val SEQUENCE = "sequence" 9 | const val CHANNELS = "channels" 10 | const val SELECTED = "selected" 11 | const val TEMPO = "tempo" 12 | const val SWING = "swing" 13 | 14 | const val CREATE_TABLE = "create table " + TABLE_NAME + 15 | "(" + 16 | ID + " integer primary key autoincrement, " + 17 | TITLE + " text unique not null, " + 18 | SEQUENCE + " text not null, " + 19 | CHANNELS + " text not null, " + 20 | SELECTED + " integer not null, " + 21 | TEMPO + " integer not null, " + 22 | SWING + " integer not null " + 23 | ");" 24 | } 25 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 15 | 16 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 34 | 35 | 40 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/App.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach 2 | 3 | import android.app.Application 4 | import dagger.hilt.android.HiltAndroidApp 5 | 6 | @HiltAndroidApp 7 | class App : Application() 8 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/Destination.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | sealed class Destination { 7 | @Serializable 8 | data object Machine : Destination() 9 | 10 | @Serializable 11 | data object Patch : Destination() 12 | } 13 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach 2 | 3 | import android.os.Bundle 4 | import android.view.WindowManager 5 | import androidx.activity.ComponentActivity 6 | import androidx.activity.compose.setContent 7 | import androidx.activity.enableEdgeToEdge 8 | import androidx.compose.animation.core.tween 9 | import androidx.compose.animation.fadeIn 10 | import androidx.compose.animation.fadeOut 11 | import androidx.compose.foundation.background 12 | import androidx.compose.foundation.layout.Box 13 | import androidx.compose.foundation.layout.Spacer 14 | import androidx.compose.foundation.layout.WindowInsets 15 | import androidx.compose.foundation.layout.asPaddingValues 16 | import androidx.compose.foundation.layout.calculateEndPadding 17 | import androidx.compose.foundation.layout.calculateStartPadding 18 | import androidx.compose.foundation.layout.displayCutout 19 | import androidx.compose.foundation.layout.fillMaxHeight 20 | import androidx.compose.foundation.layout.fillMaxSize 21 | import androidx.compose.foundation.layout.width 22 | import androidx.compose.material3.Surface 23 | import androidx.compose.ui.Alignment 24 | import androidx.compose.ui.Modifier 25 | import androidx.compose.ui.graphics.Color 26 | import androidx.compose.ui.unit.LayoutDirection 27 | import androidx.navigation.compose.NavHost 28 | import androidx.navigation.compose.composable 29 | import androidx.navigation.compose.rememberNavController 30 | import dagger.hilt.android.AndroidEntryPoint 31 | import javax.inject.Inject 32 | import net.simno.dmach.machine.MachineScreen 33 | import net.simno.dmach.patch.PatchScreen 34 | import net.simno.dmach.playback.PlaybackController 35 | import net.simno.dmach.theme.AppTheme 36 | 37 | @AndroidEntryPoint 38 | class MainActivity : ComponentActivity() { 39 | 40 | @Inject 41 | lateinit var playbackController: PlaybackController 42 | 43 | override fun onCreate(savedInstanceState: Bundle?) { 44 | enableEdgeToEdge() 45 | super.onCreate(savedInstanceState) 46 | window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) 47 | 48 | lifecycle.addObserver(playbackController) 49 | 50 | setContent { 51 | AppTheme { 52 | Surface { 53 | Box( 54 | modifier = Modifier.fillMaxSize() 55 | ) { 56 | val navController = rememberNavController() 57 | NavHost( 58 | navController = navController, 59 | startDestination = Destination.Machine, 60 | enterTransition = { fadeIn(animationSpec = tween(200)) }, 61 | exitTransition = { fadeOut(animationSpec = tween(200)) } 62 | ) { 63 | composable { 64 | MachineScreen(navigateToPatch = { navController.navigate(Destination.Patch) }) 65 | } 66 | composable { 67 | PatchScreen(navigateUp = navController::navigateUp) 68 | } 69 | } 70 | val displayCutout = WindowInsets.displayCutout.asPaddingValues() 71 | Spacer( 72 | modifier = Modifier 73 | .fillMaxHeight() 74 | .width(displayCutout.calculateStartPadding(LayoutDirection.Ltr)) 75 | .background(Color.Black) 76 | .align(Alignment.TopStart) 77 | ) 78 | Spacer( 79 | modifier = Modifier 80 | .fillMaxHeight() 81 | .width(displayCutout.calculateEndPadding(LayoutDirection.Ltr)) 82 | .background(Color.Black) 83 | .align(Alignment.TopEnd) 84 | ) 85 | } 86 | } 87 | } 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/StateViewModel.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import kotlinx.coroutines.Dispatchers 6 | import kotlinx.coroutines.channels.Channel 7 | import kotlinx.coroutines.channels.Channel.Factory.BUFFERED 8 | import kotlinx.coroutines.channels.Channel.Factory.RENDEZVOUS 9 | import kotlinx.coroutines.flow.Flow 10 | import kotlinx.coroutines.flow.SharingStarted 11 | import kotlinx.coroutines.flow.StateFlow 12 | import kotlinx.coroutines.flow.buffer 13 | import kotlinx.coroutines.flow.catch 14 | import kotlinx.coroutines.flow.emitAll 15 | import kotlinx.coroutines.flow.flow 16 | import kotlinx.coroutines.flow.flowOf 17 | import kotlinx.coroutines.flow.flowOn 18 | import kotlinx.coroutines.flow.receiveAsFlow 19 | import kotlinx.coroutines.flow.scan 20 | import kotlinx.coroutines.flow.shareIn 21 | import kotlinx.coroutines.flow.stateIn 22 | 23 | abstract class StateViewModel( 24 | processor: (Flow) -> Flow, 25 | reducer: (ViewState, Result) -> ViewState, 26 | onError: (Throwable) -> Result, 27 | startViewState: ViewState, 28 | vararg startActions: Action 29 | ) : ViewModel() { 30 | 31 | private val actionsChannel = Channel(BUFFERED) 32 | 33 | private val actions: Flow = flow { 34 | emitAll(flowOf(*startActions)) 35 | emitAll(actionsChannel.receiveAsFlow()) 36 | } 37 | 38 | val viewState: StateFlow = actions 39 | .buffer(RENDEZVOUS) 40 | .shareIn(viewModelScope, SharingStarted.Lazily) 41 | .let(processor) 42 | .catch { emit(onError(it)) } 43 | .scan(startViewState) { previousState, result -> reducer(previousState, result) } 44 | .flowOn(Dispatchers.Default) 45 | .stateIn(viewModelScope, SharingStarted.Lazily, startViewState) 46 | 47 | fun onAction(action: Action) { 48 | actionsChannel.trySend(action) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/core/CoreDialog.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.core 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.BoxScope 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.material3.BasicAlertDialog 10 | import androidx.compose.material3.MaterialTheme 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.runtime.getValue 13 | import androidx.compose.runtime.mutableFloatStateOf 14 | import androidx.compose.runtime.mutableStateOf 15 | import androidx.compose.runtime.remember 16 | import androidx.compose.runtime.setValue 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.draw.scale 19 | import androidx.compose.ui.window.DialogProperties 20 | import net.simno.dmach.theme.AppTheme 21 | 22 | @Composable 23 | fun CoreDialog( 24 | onDismiss: () -> Unit, 25 | modifier: Modifier = Modifier, 26 | properties: DialogProperties = DialogProperties(), 27 | content: @Composable BoxScope.() -> Unit 28 | ) { 29 | BasicAlertDialog( 30 | onDismissRequest = onDismiss, 31 | properties = properties 32 | ) { 33 | var backProgress by remember { mutableFloatStateOf(0f) } 34 | var inPredictiveBack by remember { mutableStateOf(false) } 35 | PredictiveBackProgress( 36 | onProgress = { backProgress = it }, 37 | onInPredictiveBack = { inPredictiveBack = it }, 38 | onBack = onDismiss 39 | ) 40 | Box( 41 | modifier = modifier 42 | .scale((1f - backProgress).coerceAtLeast(0.85f)) 43 | .background( 44 | color = MaterialTheme.colorScheme.primary, 45 | shape = MaterialTheme.shapes.medium 46 | ) 47 | .fillMaxWidth() 48 | .wrapContentHeight() 49 | .padding(AppTheme.dimens.paddingSmall), 50 | content = content 51 | ) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/core/DarkButton.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.core 2 | 3 | import androidx.compose.material3.ButtonDefaults 4 | import androidx.compose.material3.ElevatedButton 5 | import androidx.compose.material3.MaterialTheme 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.Modifier 8 | import androidx.lifecycle.compose.dropUnlessResumed 9 | 10 | @Composable 11 | fun DarkButton( 12 | text: String, 13 | onClick: () -> Unit, 14 | modifier: Modifier = Modifier 15 | ) { 16 | ElevatedButton( 17 | onClick = dropUnlessResumed(block = onClick), 18 | modifier = modifier, 19 | colors = ButtonDefaults.elevatedButtonColors( 20 | containerColor = MaterialTheme.colorScheme.primary 21 | ) 22 | ) { 23 | LightMediumLabel(text) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/core/HapticClick.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.core 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.hapticfeedback.HapticFeedbackType 5 | import androidx.compose.ui.platform.LocalHapticFeedback 6 | 7 | @Composable 8 | fun hapticClick(block: (() -> Unit)?): () -> Unit { 9 | val haptic = LocalHapticFeedback.current 10 | return { 11 | block?.let { 12 | haptic.performHapticFeedback(HapticFeedbackType.LongPress) 13 | it() 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/core/LightButton.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.core 2 | 3 | import androidx.compose.material3.ElevatedButton 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.ui.Modifier 6 | import androidx.lifecycle.compose.dropUnlessResumed 7 | 8 | @Composable 9 | fun LightButton( 10 | text: String, 11 | onClick: () -> Unit, 12 | modifier: Modifier = Modifier 13 | ) { 14 | ElevatedButton( 15 | onClick = dropUnlessResumed(block = onClick), 16 | modifier = modifier 17 | ) { 18 | DarkMediumLabel(text) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/core/OptionsDialog.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.core 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.Arrangement 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.padding 10 | import androidx.compose.foundation.layout.size 11 | import androidx.compose.foundation.layout.wrapContentHeight 12 | import androidx.compose.material3.CircularProgressIndicator 13 | import androidx.compose.material3.MaterialTheme 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 androidx.compose.ui.window.DialogProperties 19 | import net.simno.dmach.theme.AppTheme 20 | 21 | @Composable 22 | fun OptionsDialog( 23 | text: String, 24 | option1Text: String, 25 | option2Text: String, 26 | onDismiss: () -> Unit, 27 | onOption1: () -> Unit, 28 | onOption2: () -> Unit, 29 | modifier: Modifier = Modifier, 30 | enabled: Boolean = true, 31 | properties: DialogProperties = DialogProperties() 32 | ) { 33 | val surface = MaterialTheme.colorScheme.surface 34 | val onPrimary = MaterialTheme.colorScheme.onPrimary 35 | val shapeMedium = MaterialTheme.shapes.medium 36 | val paddingLarge = AppTheme.dimens.paddingLarge 37 | 38 | CoreDialog( 39 | onDismiss = onDismiss, 40 | properties = properties 41 | ) { 42 | Column( 43 | modifier = modifier 44 | .background( 45 | color = onPrimary, 46 | shape = shapeMedium 47 | ) 48 | .fillMaxWidth() 49 | .wrapContentHeight() 50 | .padding(paddingLarge), 51 | verticalArrangement = Arrangement.spacedBy(24.dp) 52 | ) { 53 | LightMediumText(text = text) 54 | if (enabled) { 55 | Row( 56 | modifier = Modifier.fillMaxWidth(), 57 | horizontalArrangement = Arrangement.End 58 | ) { 59 | LightButton( 60 | text = option1Text, 61 | onClick = onOption1 62 | ) 63 | Spacer(modifier = Modifier.size(paddingLarge)) 64 | LightButton( 65 | text = option2Text, 66 | onClick = onOption2 67 | ) 68 | } 69 | } else { 70 | CircularProgressIndicator( 71 | modifier = Modifier.align(Alignment.CenterHorizontally), 72 | color = surface 73 | ) 74 | } 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/core/PredictiveBackProgress.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.core 2 | 3 | import androidx.activity.compose.PredictiveBackHandler 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.getValue 6 | import androidx.compose.runtime.rememberUpdatedState 7 | import kotlin.coroutines.cancellation.CancellationException 8 | 9 | @Composable 10 | fun PredictiveBackProgress( 11 | onProgress: (Float) -> Unit, 12 | onInPredictiveBack: (Boolean) -> Unit, 13 | onBack: () -> Unit, 14 | enabled: Boolean = true 15 | ) { 16 | val currentOnProgress by rememberUpdatedState(onProgress) 17 | val currentOnInPredictiveBack by rememberUpdatedState(onInPredictiveBack) 18 | val currentOnBack by rememberUpdatedState(onBack) 19 | 20 | PredictiveBackHandler(enabled = enabled) { backEvents -> 21 | currentOnProgress(0f) 22 | try { 23 | backEvents.collect { event -> 24 | currentOnInPredictiveBack(true) 25 | currentOnProgress(event.progress) 26 | } 27 | currentOnInPredictiveBack(false) 28 | currentOnBack() 29 | } catch (e: CancellationException) { 30 | currentOnInPredictiveBack(false) 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/core/Text.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.core 2 | 3 | import androidx.compose.material3.MaterialTheme 4 | import androidx.compose.material3.Text 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.Modifier 7 | import androidx.compose.ui.text.style.TextAlign 8 | import androidx.compose.ui.text.style.TextOverflow 9 | 10 | @Composable 11 | fun LightLargeText( 12 | text: String, 13 | modifier: Modifier = Modifier 14 | ) { 15 | Text( 16 | text = text, 17 | modifier = modifier, 18 | color = MaterialTheme.colorScheme.surface, 19 | maxLines = 1, 20 | style = MaterialTheme.typography.bodyLarge 21 | ) 22 | } 23 | 24 | @Composable 25 | fun DarkLargeText( 26 | text: String, 27 | modifier: Modifier = Modifier, 28 | textAlign: TextAlign? = null 29 | ) { 30 | Text( 31 | text = text, 32 | modifier = modifier, 33 | color = MaterialTheme.colorScheme.primary, 34 | textAlign = textAlign, 35 | maxLines = 1, 36 | style = MaterialTheme.typography.bodyLarge 37 | ) 38 | } 39 | 40 | @Composable 41 | fun LightMediumText( 42 | text: String, 43 | modifier: Modifier = Modifier 44 | ) { 45 | Text( 46 | text = text, 47 | modifier = modifier, 48 | color = MaterialTheme.colorScheme.surface, 49 | maxLines = 1, 50 | style = MaterialTheme.typography.bodyMedium 51 | ) 52 | } 53 | 54 | @Composable 55 | fun DarkMediumText( 56 | text: String, 57 | modifier: Modifier = Modifier, 58 | textAlign: TextAlign? = null 59 | ) { 60 | Text( 61 | text = text, 62 | modifier = modifier, 63 | color = MaterialTheme.colorScheme.primary, 64 | textAlign = textAlign, 65 | maxLines = 1, 66 | style = MaterialTheme.typography.bodyMedium 67 | ) 68 | } 69 | 70 | @Composable 71 | fun LightSmallText( 72 | text: String, 73 | modifier: Modifier = Modifier 74 | ) { 75 | Text( 76 | text = text, 77 | modifier = modifier, 78 | color = MaterialTheme.colorScheme.surface, 79 | style = MaterialTheme.typography.bodySmall 80 | ) 81 | } 82 | 83 | @Composable 84 | fun DarkSmallText( 85 | text: String, 86 | modifier: Modifier = Modifier 87 | ) { 88 | Text( 89 | text = text, 90 | modifier = modifier, 91 | color = MaterialTheme.colorScheme.primary.copy(alpha = 0.666f), 92 | style = MaterialTheme.typography.bodySmall 93 | ) 94 | } 95 | 96 | @Composable 97 | fun LightMediumLabel( 98 | text: String, 99 | modifier: Modifier = Modifier 100 | ) { 101 | Text( 102 | text = text, 103 | modifier = modifier, 104 | color = MaterialTheme.colorScheme.surface, 105 | maxLines = 1, 106 | style = MaterialTheme.typography.labelMedium 107 | ) 108 | } 109 | 110 | @Composable 111 | fun DarkMediumLabel( 112 | text: String, 113 | modifier: Modifier = Modifier, 114 | textAlign: TextAlign? = null 115 | ) { 116 | Text( 117 | text = text, 118 | modifier = modifier, 119 | color = MaterialTheme.colorScheme.primary, 120 | textAlign = textAlign, 121 | maxLines = 1, 122 | style = MaterialTheme.typography.labelMedium 123 | ) 124 | } 125 | 126 | @Composable 127 | fun DarkSmallLabel( 128 | text: String, 129 | modifier: Modifier = Modifier 130 | ) { 131 | Text( 132 | text = text, 133 | modifier = modifier, 134 | color = MaterialTheme.colorScheme.primary, 135 | overflow = TextOverflow.Ellipsis, 136 | maxLines = 1, 137 | style = MaterialTheme.typography.labelSmall 138 | ) 139 | } 140 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/data/Channel.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.data 2 | 3 | import kotlinx.serialization.Serializable 4 | import kotlinx.serialization.Transient 5 | 6 | @Serializable 7 | data class Channel( 8 | val name: String, 9 | val settings: List, 10 | val selectedSetting: Int, 11 | val pan: Pan 12 | ) { 13 | @Transient 14 | val setting: Setting = settings.getOrElse(selectedSetting) { Setting.EMPTY } 15 | 16 | companion object { 17 | const val NONE_ID = -1 18 | 19 | val NONE: Channel = Channel("none", emptyList(), 0, Pan(0.5f)) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/data/DefaultPatch.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.data 2 | 3 | import kotlinx.collections.immutable.persistentListOf 4 | import kotlinx.collections.immutable.persistentSetOf 5 | 6 | fun defaultPatch(): Patch { 7 | val bd = Channel( 8 | name = "bd", 9 | settings = listOf( 10 | Setting(hText = "Pitch A", vText = "Gain", hIndex = 0, vIndex = 7, x = .4f, y = .49f), 11 | Setting(hText = "Low-pass", vText = "Square", hIndex = 5, vIndex = 3, x = .7f, y = 0f), 12 | Setting(hText = "Pitch B", vText = "Curve Time", hIndex = 1, vIndex = 2, x = .4f, y = .4f), 13 | Setting(hText = "Decay", vText = "Noise Level", hIndex = 6, vIndex = 4, x = .49f, y = .7f) 14 | ), 15 | selectedSetting = 0, 16 | pan = Pan(.5f) 17 | ) 18 | val sd = Channel( 19 | name = "sd", 20 | settings = listOf( 21 | Setting(hText = "Pitch", vText = "Gain", hIndex = 0, vIndex = 9, x = .49f, y = .45f), 22 | Setting(hText = "Low-pass", vText = "Noise", hIndex = 7, vIndex = 1, x = .6f, y = .8f), 23 | Setting(hText = "X-fade", vText = "Attack", hIndex = 8, vIndex = 6, x = .35f, y = .55f), 24 | Setting(hText = "Decay", vText = "Body Decay", hIndex = 4, vIndex = 5, x = .55f, y = .42f), 25 | Setting(hText = "Band-pass", vText = "Band-pass Q", hIndex = 2, vIndex = 3, x = .7f, y = .6f) 26 | ), 27 | selectedSetting = 0, 28 | pan = Pan(.5f) 29 | ) 30 | val cp = Channel( 31 | name = "cp", 32 | settings = listOf( 33 | Setting(hText = "Pitch", vText = "Gain", hIndex = 0, vIndex = 7, x = .55f, y = .3f), 34 | Setting(hText = "Delay 1", vText = "Delay 2", hIndex = 4, vIndex = 5, x = .3f, y = .3f), 35 | Setting(hText = "Decay", vText = "Filter Q", hIndex = 6, vIndex = 1, x = .59f, y = .2f), 36 | Setting(hText = "Filter 1", vText = "Filter 2", hIndex = 2, vIndex = 3, x = .9f, y = .15f) 37 | ), 38 | selectedSetting = 0, 39 | pan = Pan(.5f) 40 | ) 41 | val tt = Channel( 42 | name = "tt", 43 | settings = listOf( 44 | Setting(hText = "Pitch", vText = "Gain", hIndex = 0, vIndex = 1, x = .49f, y = .49f) 45 | ), 46 | selectedSetting = 0, 47 | pan = Pan(.5f) 48 | ) 49 | val cb = Channel( 50 | name = "cb", 51 | settings = listOf( 52 | Setting(hText = "Pitch", vText = "Gain", hIndex = 0, vIndex = 5, x = .3f, y = .49f), 53 | Setting(hText = "Decay 1", vText = "Decay 2", hIndex = 1, vIndex = 2, x = .1f, y = .75f), 54 | Setting(hText = "Vcf", vText = "Vcf Q", hIndex = 3, vIndex = 4, x = .3f, y = 0f) 55 | ), 56 | selectedSetting = 0, 57 | pan = Pan(.5f) 58 | ) 59 | val hh = Channel( 60 | name = "hh", 61 | settings = listOf( 62 | Setting(hText = "Pitch", vText = "Gain", hIndex = 0, vIndex = 11, x = .45f, y = .4f), 63 | Setting(hText = "Low-pass", vText = "Snap", hIndex = 10, vIndex = 5, x = .8f, y = .1f), 64 | Setting(hText = "Noise Pitch", vText = "Noise", hIndex = 4, vIndex = 3, x = .55f, y = .6f), 65 | Setting(hText = "Ratio B", vText = "Ratio A", hIndex = 2, vIndex = 1, x = .9f, y = 1f), 66 | Setting(hText = "Release", vText = "Attack", hIndex = 7, vIndex = 6, x = .55f, y = .4f), 67 | Setting(hText = "Filter", vText = "Filter Q", hIndex = 8, vIndex = 9, x = .7f, y = .6f) 68 | ), 69 | selectedSetting = 0, 70 | pan = Pan(.5f) 71 | ) 72 | return Patch( 73 | title = "untitled", 74 | sequence = Patch.EMPTY_SEQUENCE, 75 | mutedChannels = persistentSetOf(), 76 | channels = persistentListOf(bd, sd, cp, tt, cb, hh), 77 | selectedChannel = Channel.NONE_ID, 78 | tempo = Tempo(120), 79 | swing = Swing(0), 80 | steps = Steps(16) 81 | ) 82 | } 83 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/data/Pan.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.data 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | @JvmInline 7 | value class Pan( 8 | val value: Float 9 | ) 10 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/data/Patch.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.data 2 | 3 | import kotlinx.collections.immutable.PersistentList 4 | import kotlinx.collections.immutable.PersistentSet 5 | import kotlinx.collections.immutable.toPersistentList 6 | 7 | data class Patch( 8 | val title: String, 9 | val sequence: PersistentList, 10 | val mutedChannels: PersistentSet, 11 | val channels: PersistentList, 12 | val selectedChannel: Int, 13 | val tempo: Tempo, 14 | val swing: Swing, 15 | val steps: Steps 16 | ) { 17 | val channel: Channel = channels.getOrElse(selectedChannel) { Channel.NONE } 18 | 19 | companion object { 20 | const val STEPS = 16 21 | const val CHANNELS = 6 22 | val MASKS = intArrayOf(1, 2, 4) 23 | val MUTED_MASKS = MASKS.map { 7 - it }.toIntArray() 24 | val EMPTY_SEQUENCE = (0..31).map { 0 }.toPersistentList() 25 | } 26 | } 27 | 28 | fun Patch.mutedSequence(): List = sequence.mapIndexed { index, step -> 29 | val offset = if (index < Patch.STEPS) 0 else Patch.MUTED_MASKS.size 30 | Patch.MUTED_MASKS.foldIndexed(step) { maskIndex, maskedStep, mask -> 31 | when { 32 | mutedChannels.contains(maskIndex + offset) -> maskedStep and mask 33 | else -> maskedStep 34 | } 35 | } 36 | } 37 | 38 | fun Patch.withPan(pan: Pan): Patch = copy( 39 | channels = channels.map { ch -> 40 | if (ch == channel) ch.copy(pan = pan) else ch 41 | }.toPersistentList() 42 | ) 43 | 44 | fun Patch.withSelectedSetting(selectedSetting: Int): Patch = copy( 45 | channels = channels.map { ch -> 46 | if (ch == channel) ch.copy(selectedSetting = selectedSetting) else ch 47 | }.toPersistentList() 48 | ) 49 | 50 | fun Patch.withPosition(position: Position): Patch { 51 | val setting = channel.setting 52 | return copy( 53 | channels = channels.map { ch -> 54 | if (ch == channel) { 55 | ch.copy( 56 | settings = ch.settings.map { s -> 57 | if (s == setting) s.copy(x = position.x, y = position.y) else s 58 | }.toPersistentList() 59 | ) 60 | } else { 61 | ch 62 | } 63 | }.toPersistentList() 64 | ) 65 | } 66 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/data/Position.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.data 2 | 3 | data class Position( 4 | val x: Float, 5 | val y: Float 6 | ) 7 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/data/Setting.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.data 2 | 3 | import kotlinx.serialization.Serializable 4 | import kotlinx.serialization.Transient 5 | 6 | @Serializable 7 | data class Setting( 8 | val hText: String, 9 | val vText: String, 10 | val hIndex: Int, 11 | val vIndex: Int, 12 | val x: Float, 13 | val y: Float 14 | ) { 15 | @Transient 16 | val position: Position = Position(x, y) 17 | 18 | companion object { 19 | val EMPTY: Setting = Setting("", "", 0, 0, 0.5f, 0.5f) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/data/Steps.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.data 2 | 3 | @JvmInline 4 | value class Steps( 5 | val value: Int 6 | ) 7 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/data/Swing.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.data 2 | 3 | @JvmInline 4 | value class Swing( 5 | val value: Int 6 | ) 7 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/data/Tempo.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.data 2 | 3 | @JvmInline 4 | value class Tempo( 5 | val value: Int 6 | ) 7 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/db/DbModule.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.db 2 | 3 | import android.content.Context 4 | import androidx.room.Room 5 | import dagger.Module 6 | import dagger.Provides 7 | import dagger.hilt.InstallIn 8 | import dagger.hilt.android.qualifiers.ApplicationContext 9 | import dagger.hilt.components.SingletonComponent 10 | import javax.inject.Singleton 11 | import kotlinx.coroutines.runBlocking 12 | import net.simno.dmach.data.defaultPatch 13 | import net.simno.dmach.db.PatchRepository.Companion.toEntity 14 | 15 | @Module 16 | @InstallIn(SingletonComponent::class) 17 | object DbModule { 18 | @Provides 19 | @Singleton 20 | fun providePatchDatabase(@ApplicationContext context: Context): PatchDatabase { 21 | val db = Room 22 | .databaseBuilder(context, PatchDatabase::class.java, PatchDatabase.NAME) 23 | .addMigrations( 24 | PatchDatabase.MIGRATION_2_3, 25 | PatchDatabase.MIGRATION_3_4, 26 | PatchDatabase.MIGRATION_4_5 27 | ) 28 | .fallbackToDestructiveMigration(dropAllTables = true) 29 | .build() 30 | 31 | val dao = db.patchDao() 32 | runBlocking { 33 | if (dao.count() == 0) { 34 | val defaultPatch = defaultPatch() 35 | dao.insertPatch(defaultPatch.toEntity(defaultPatch.title)) 36 | } 37 | } 38 | 39 | return db 40 | } 41 | 42 | @Provides 43 | @Singleton 44 | fun providePatchRepository( 45 | patchDatabase: PatchDatabase 46 | ): PatchRepository = PatchRepository(patchDatabase.patchDao()) 47 | } 48 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/db/PatchDao.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.db 2 | 3 | import androidx.paging.PagingSource 4 | import androidx.room.Dao 5 | import androidx.room.Insert 6 | import androidx.room.OnConflictStrategy 7 | import androidx.room.Query 8 | import androidx.room.Transaction 9 | import kotlinx.coroutines.flow.Flow 10 | 11 | @Dao 12 | interface PatchDao { 13 | 14 | @Query("SELECT COUNT(*) FROM patch") 15 | suspend fun count(): Int 16 | 17 | @Query("SELECT * FROM patch WHERE active = 1 LIMIT 1") 18 | fun getActivePatch(): Flow 19 | 20 | @Query("SELECT * FROM patch ORDER BY title") 21 | fun getAllPatches(): PagingSource 22 | 23 | @Query("DELETE FROM patch WHERE title = :title") 24 | suspend fun deletePatch(title: String): Int 25 | 26 | @Query("UPDATE patch SET active = 0 WHERE active = 1") 27 | suspend fun internalResetActive() 28 | 29 | @Query("UPDATE patch SET active = 1 WHERE title = :title") 30 | suspend fun internalSetActive(title: String): Int 31 | 32 | @Insert(onConflict = OnConflictStrategy.ABORT) 33 | suspend fun internalInsertPatch(patch: PatchEntity): Long 34 | 35 | @Insert(onConflict = OnConflictStrategy.REPLACE) 36 | suspend fun internalReplacePatch(patch: PatchEntity): Long 37 | 38 | @Transaction 39 | suspend fun selectPatch(title: String): Int { 40 | internalResetActive() 41 | return internalSetActive(title) 42 | } 43 | 44 | @Transaction 45 | suspend fun insertPatch(patch: PatchEntity): Long { 46 | internalResetActive() 47 | return internalInsertPatch(patch) 48 | } 49 | 50 | @Transaction 51 | suspend fun replacePatch(patch: PatchEntity): Long { 52 | internalResetActive() 53 | return internalReplacePatch(patch) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/db/PatchDatabase.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.db 2 | 3 | import androidx.room.Database 4 | import androidx.room.RoomDatabase 5 | import androidx.room.migration.Migration 6 | import androidx.sqlite.db.SupportSQLiteDatabase 7 | 8 | @Database( 9 | entities = [PatchEntity::class], 10 | version = 5, 11 | exportSchema = true 12 | ) 13 | abstract class PatchDatabase : RoomDatabase() { 14 | 15 | abstract fun patchDao(): PatchDao 16 | 17 | companion object { 18 | const val NAME = "dmach.db" 19 | 20 | val MIGRATION_2_3: Migration = object : Migration(2, 3) { 21 | override fun migrate(db: SupportSQLiteDatabase) { 22 | db.execSQL("ALTER TABLE patch ADD active INTEGER NOT NULL DEFAULT 1;") 23 | db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS index_patch_title ON patch (title)") 24 | } 25 | } 26 | 27 | val MIGRATION_3_4: Migration = object : Migration(3, 4) { 28 | override fun migrate(db: SupportSQLiteDatabase) { 29 | db.execSQL("ALTER TABLE patch ADD steps INTEGER NOT NULL DEFAULT 16;") 30 | } 31 | } 32 | 33 | val MIGRATION_4_5: Migration = object : Migration(4, 5) { 34 | override fun migrate(db: SupportSQLiteDatabase) { 35 | db.execSQL("ALTER TABLE patch ADD muted TEXT NOT NULL DEFAULT '[]';") 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/db/PatchEntity.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.db 2 | 3 | import androidx.room.ColumnInfo 4 | import androidx.room.Entity 5 | import androidx.room.Index 6 | import androidx.room.PrimaryKey 7 | 8 | @Entity( 9 | tableName = "patch", 10 | indices = [ 11 | Index("title", unique = true) 12 | ] 13 | ) 14 | data class PatchEntity( 15 | @PrimaryKey(autoGenerate = true) 16 | @ColumnInfo(name = "_id") val id: Int?, 17 | @ColumnInfo(name = "title") val title: String, 18 | @ColumnInfo(name = "sequence") val sequence: String, 19 | @ColumnInfo(name = "muted") val muted: String, 20 | @ColumnInfo(name = "channels") val channels: String, 21 | @ColumnInfo(name = "selected") val selected: Int, 22 | @ColumnInfo(name = "tempo") val tempo: Int, 23 | @ColumnInfo(name = "swing") val swing: Int, 24 | @ColumnInfo(name = "steps") val steps: Int, 25 | @ColumnInfo(name = "active") val active: Boolean 26 | ) 27 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/db/PatchRepository.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.db 2 | 3 | import android.database.sqlite.SQLiteConstraintException 4 | import androidx.paging.PagingSource 5 | import kotlinx.collections.immutable.toPersistentList 6 | import kotlinx.collections.immutable.toPersistentSet 7 | import kotlinx.coroutines.Dispatchers.Default 8 | import kotlinx.coroutines.Dispatchers.IO 9 | import kotlinx.coroutines.flow.Flow 10 | import kotlinx.coroutines.flow.MutableSharedFlow 11 | import kotlinx.coroutines.flow.MutableStateFlow 12 | import kotlinx.coroutines.flow.filterNotNull 13 | import kotlinx.coroutines.flow.map 14 | import kotlinx.coroutines.flow.onEach 15 | import kotlinx.coroutines.withContext 16 | import kotlinx.serialization.builtins.ListSerializer 17 | import kotlinx.serialization.builtins.SetSerializer 18 | import kotlinx.serialization.builtins.serializer 19 | import kotlinx.serialization.json.Json 20 | import net.simno.dmach.data.Channel 21 | import net.simno.dmach.data.Patch 22 | import net.simno.dmach.data.Steps 23 | import net.simno.dmach.data.Swing 24 | import net.simno.dmach.data.Tempo 25 | 26 | class PatchRepository( 27 | private val patchDao: PatchDao 28 | ) { 29 | private val unsavedPatch = MutableSharedFlow(replay = 1) 30 | private val deleteTitle = MutableStateFlow("") 31 | private val saveTitle = MutableStateFlow("") 32 | 33 | fun patches(): PagingSource = patchDao.getAllPatches() 34 | 35 | suspend fun acceptPatch(patch: Patch) = withContext(IO) { 36 | unsavedPatch.tryEmit(patch) 37 | } 38 | 39 | suspend fun acceptDeleteTitle(title: String) = withContext(IO) { 40 | deleteTitle.emit(title) 41 | } 42 | 43 | suspend fun unsavedPatch(): Patch = withContext(IO) { 44 | unsavedPatch.replayCache.first() 45 | } 46 | 47 | fun activePatch(): Flow = patchDao.getActivePatch() 48 | .filterNotNull() 49 | .map { it.toPatch() } 50 | .onEach { acceptPatch(it) } 51 | 52 | suspend fun selectPatch(title: String): Int = withContext(IO) { 53 | patchDao.selectPatch(title) 54 | } 55 | 56 | suspend fun deletePatch(): Int = withContext(IO) { 57 | patchDao.deletePatch(deleteTitle.value) 58 | } 59 | 60 | suspend fun insertPatch(title: String): Boolean = withContext(IO) { 61 | val patch = unsavedPatch().toEntity(title) 62 | val inserted = try { 63 | patchDao.insertPatch(patch) != 0L 64 | } catch (ignored: SQLiteConstraintException) { 65 | false 66 | } 67 | if (!inserted) { 68 | saveTitle.emit(title) 69 | } 70 | inserted 71 | } 72 | 73 | suspend fun replacePatch(): Long = withContext(IO) { 74 | val patch = unsavedPatch().toEntity(saveTitle.value) 75 | patchDao.replacePatch(patch) 76 | } 77 | 78 | companion object { 79 | suspend fun PatchEntity.toPatch(): Patch = withContext(Default) { 80 | Patch( 81 | title = title, 82 | sequence = Json.decodeFromString(ListSerializer(Int.serializer()), sequence).toPersistentList(), 83 | mutedChannels = Json.decodeFromString(SetSerializer(Int.serializer()), muted).toPersistentSet(), 84 | channels = Json.decodeFromString(ListSerializer(Channel.serializer()), channels).toPersistentList(), 85 | selectedChannel = selected, 86 | tempo = Tempo(tempo), 87 | swing = Swing(swing), 88 | steps = Steps(steps) 89 | ) 90 | } 91 | 92 | suspend fun Patch.toEntity(title: String): PatchEntity = withContext(Default) { 93 | PatchEntity( 94 | id = null, 95 | title = title, 96 | sequence = Json.encodeToString(ListSerializer(Int.serializer()), sequence), 97 | muted = Json.encodeToString(SetSerializer(Int.serializer()), mutedChannels), 98 | channels = Json.encodeToString(ListSerializer(Channel.serializer()), channels), 99 | selected = selectedChannel, 100 | tempo = tempo.value, 101 | swing = swing.value, 102 | steps = steps.value, 103 | active = true 104 | ) 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/machine/MachineModule.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.machine 2 | 3 | import dagger.Module 4 | import dagger.Provides 5 | import dagger.hilt.InstallIn 6 | import dagger.hilt.android.components.ViewModelComponent 7 | import dagger.hilt.android.scopes.ViewModelScoped 8 | import net.simno.dmach.db.PatchRepository 9 | import net.simno.dmach.machine.state.MachineProcessor 10 | import net.simno.dmach.playback.AudioFocus 11 | import net.simno.dmach.playback.PlaybackController 12 | import net.simno.dmach.playback.PureData 13 | import net.simno.dmach.playback.WaveExporter 14 | import net.simno.dmach.settings.SettingsRepository 15 | 16 | @Module 17 | @InstallIn(ViewModelComponent::class) 18 | object MachineModule { 19 | @Provides 20 | @ViewModelScoped 21 | fun provideMachineProcesssor( 22 | playbackController: PlaybackController, 23 | pureData: PureData, 24 | waveExporter: WaveExporter, 25 | audioFocus: AudioFocus, 26 | patchRepository: PatchRepository, 27 | settingsRepository: SettingsRepository 28 | ): MachineProcessor = MachineProcessor( 29 | playbackController, 30 | pureData, 31 | waveExporter, 32 | audioFocus, 33 | patchRepository, 34 | settingsRepository 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/machine/MachineScreen.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.machine 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.getValue 5 | import androidx.hilt.navigation.compose.hiltViewModel 6 | import androidx.lifecycle.compose.collectAsStateWithLifecycle 7 | import net.simno.dmach.machine.ui.Machine 8 | 9 | @Composable 10 | fun MachineScreen( 11 | navigateToPatch: () -> Unit, 12 | viewModel: MachineViewModel = hiltViewModel() 13 | ) { 14 | val state by viewModel.viewState.collectAsStateWithLifecycle() 15 | 16 | Machine( 17 | state = state, 18 | onAction = viewModel::onAction, 19 | onClickPatch = navigateToPatch 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/machine/MachineViewModel.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.machine 2 | 3 | import dagger.hilt.android.lifecycle.HiltViewModel 4 | import javax.inject.Inject 5 | import net.simno.dmach.StateViewModel 6 | import net.simno.dmach.machine.state.Action 7 | import net.simno.dmach.machine.state.ErrorResult 8 | import net.simno.dmach.machine.state.LoadAction 9 | import net.simno.dmach.machine.state.MachineProcessor 10 | import net.simno.dmach.machine.state.MachineStateReducer 11 | import net.simno.dmach.machine.state.PlaybackAction 12 | import net.simno.dmach.machine.state.Result 13 | import net.simno.dmach.machine.state.SettingsAction 14 | import net.simno.dmach.machine.state.ViewState 15 | 16 | @HiltViewModel 17 | class MachineViewModel @Inject constructor( 18 | processor: MachineProcessor 19 | ) : StateViewModel( 20 | processor = processor, 21 | reducer = MachineStateReducer, 22 | onError = { ErrorResult(it) }, 23 | startViewState = ViewState(), 24 | LoadAction, 25 | PlaybackAction, 26 | SettingsAction 27 | ) 28 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/machine/state/Action.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.machine.state 2 | 3 | import kotlinx.collections.immutable.PersistentList 4 | import net.simno.dmach.data.Pan 5 | import net.simno.dmach.data.Position 6 | import net.simno.dmach.data.Steps 7 | import net.simno.dmach.data.Swing 8 | import net.simno.dmach.data.Tempo 9 | import net.simno.dmach.settings.Settings 10 | 11 | sealed class Action 12 | 13 | data class DebugAction( 14 | val debug: Boolean 15 | ) : Action() 16 | 17 | data object LoadAction : Action() 18 | 19 | data object ResumeAction : Action() 20 | 21 | data object PlaybackAction : Action() 22 | 23 | data class PlayPauseAction( 24 | val play: Boolean 25 | ) : Action() 26 | 27 | data object SettingsAction : Action() 28 | 29 | data class ChangeSettingsAction( 30 | val settings: Settings 31 | ) : Action() 32 | 33 | data object ConfigAction : Action() 34 | 35 | data object ExportAction : Action() 36 | 37 | data class ExportFileAction( 38 | val title: String, 39 | val tempo: Tempo, 40 | val steps: Steps 41 | ) : Action() 42 | 43 | data object DismissAction : Action() 44 | 45 | data class ChangeSequenceAction( 46 | val sequenceId: Int, 47 | val sequence: PersistentList 48 | ) : Action() 49 | 50 | data class MuteChannelAction( 51 | val channel: Int, 52 | val isMuted: Boolean 53 | ) : Action() 54 | 55 | data class SelectChannelAction( 56 | val channel: Int, 57 | val isSelected: Boolean 58 | ) : Action() 59 | 60 | data class SelectSettingAction( 61 | val setting: Int 62 | ) : Action() 63 | 64 | data class ChangePositionAction( 65 | val position: Position 66 | ) : Action() 67 | 68 | data class ChangePanAction( 69 | val pan: Pan 70 | ) : Action() 71 | 72 | data class ChangeTempoAction( 73 | val tempo: Tempo 74 | ) : Action() 75 | 76 | data class ChangeSwingAction( 77 | val swing: Swing 78 | ) : Action() 79 | 80 | data class ChangeStepsAction( 81 | val steps: Steps 82 | ) : Action() 83 | 84 | sealed class ChangePatchAction( 85 | open val settings: Settings 86 | ) : Action() { 87 | data class Reset( 88 | override val settings: Settings 89 | ) : ChangePatchAction(settings) 90 | 91 | data class Randomize( 92 | override val settings: Settings 93 | ) : ChangePatchAction(settings) 94 | } 95 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/machine/state/MachineStateReducer.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.machine.state 2 | 3 | import net.simno.dmach.util.logError 4 | 5 | object MachineStateReducer : (ViewState, Result) -> ViewState { 6 | override fun invoke(previousState: ViewState, result: Result) = when (result) { 7 | is ErrorResult -> { 8 | logError("MachineStateReducer", "ErrorResult", result.error) 9 | previousState 10 | } 11 | is DebugResult -> previousState.copy( 12 | debug = result.debug 13 | ) 14 | is LoadResult -> previousState.copy( 15 | title = result.title, 16 | sequenceId = result.sequenceId, 17 | sequence = result.sequence, 18 | mutedChannels = result.mutedChannels, 19 | selectedChannel = result.selectedChannel, 20 | selectedSetting = result.selectedSetting, 21 | settingsSize = result.settingsSize, 22 | settingId = result.settingId, 23 | hText = result.hText, 24 | vText = result.vText, 25 | position = result.position, 26 | panId = result.panId, 27 | pan = result.pan, 28 | tempo = result.tempo, 29 | swing = result.swing, 30 | steps = result.steps 31 | ) 32 | is ResumeResult -> previousState.copy( 33 | settingId = result.settingId, 34 | position = result.position, 35 | panId = result.panId, 36 | pan = result.pan 37 | ) 38 | is PlaybackResult -> previousState.copy( 39 | isPlaying = result.isPlaying, 40 | position = null, 41 | pan = null 42 | ) 43 | PlayPauseResult -> previousState.copy( 44 | position = null, 45 | pan = null 46 | ) 47 | is SettingsResult -> previousState.copy( 48 | settings = result.settings, 49 | position = null, 50 | pan = null 51 | ) 52 | ChangeSettingsResult -> previousState.copy( 53 | position = null, 54 | pan = null 55 | ) 56 | is ConfigResult -> previousState.copy( 57 | showConfig = true, 58 | configId = result.configId, 59 | position = null, 60 | pan = null 61 | ) 62 | ExportResult -> previousState.copy( 63 | showExport = true, 64 | startExport = true, 65 | waveFile = null, 66 | position = null, 67 | pan = null 68 | ) 69 | is ExportFileResult -> previousState.copy( 70 | showExport = result.waveFile != null, 71 | startExport = false, 72 | waveFile = result.waveFile, 73 | position = null, 74 | pan = null 75 | ) 76 | DismissResult -> previousState.copy( 77 | showConfig = false, 78 | showExport = false, 79 | startExport = false, 80 | waveFile = null, 81 | position = null, 82 | pan = null 83 | ) 84 | is ChangeSequenceResult -> previousState.copy( 85 | sequenceId = result.sequenceId, 86 | sequence = result.sequence, 87 | position = null, 88 | pan = null 89 | ) 90 | is MuteChannelResult -> previousState.copy( 91 | mutedChannels = result.mutedChannels 92 | ) 93 | is SelectChannelResult -> previousState.copy( 94 | selectedChannel = result.selectedChannel, 95 | selectedSetting = result.selectedSetting, 96 | settingId = result.settingId, 97 | settingsSize = result.settingsSize, 98 | hText = result.hText, 99 | vText = result.vText, 100 | position = result.position, 101 | panId = result.panId, 102 | pan = result.pan 103 | ) 104 | is SelectSettingResult -> previousState.copy( 105 | selectedSetting = result.selectedSetting, 106 | settingId = result.settingId, 107 | hText = result.hText, 108 | vText = result.vText, 109 | position = result.position 110 | ) 111 | ChangePositionResult -> previousState.copy( 112 | position = null, 113 | pan = null 114 | ) 115 | ChangePanResult -> previousState.copy( 116 | position = null, 117 | pan = null 118 | ) 119 | is ChangeTempoResult -> previousState.copy( 120 | position = null, 121 | pan = null, 122 | tempo = result.tempo 123 | ) 124 | is ChangeSwingResult -> previousState.copy( 125 | position = null, 126 | pan = null, 127 | swing = result.swing 128 | ) 129 | is ChangeStepsResult -> previousState.copy( 130 | position = null, 131 | pan = null, 132 | sequenceId = result.sequenceId, 133 | steps = result.steps 134 | ) 135 | is ChangePatchResult -> previousState.copy( 136 | sequenceId = result.sequenceId, 137 | sequence = result.sequence, 138 | panId = result.panId, 139 | pan = result.pan, 140 | settingId = result.settingId, 141 | position = result.position 142 | ) 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/machine/state/Randomizer.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.machine.state 2 | 3 | import kotlin.random.Random 4 | import kotlinx.collections.immutable.PersistentList 5 | import kotlinx.collections.immutable.toPersistentList 6 | import net.simno.dmach.data.Patch 7 | 8 | interface Randomizer { 9 | fun nextSequence(): PersistentList 10 | fun nextFloat(): Float 11 | fun nextInt(): Int 12 | 13 | companion object { 14 | val DEFAULT = object : Randomizer { 15 | override fun nextSequence(): PersistentList = Patch.EMPTY_SEQUENCE 16 | .map { Random.nextInt(12) } 17 | .map { if (it < 8) it else 0 } 18 | .toPersistentList() 19 | 20 | override fun nextFloat(): Float = Random.nextDouble(0.0, 1.0).toFloat() 21 | 22 | override fun nextInt(): Int = Random.nextInt() 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/machine/state/Result.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.machine.state 2 | 3 | import java.io.File 4 | import kotlinx.collections.immutable.PersistentList 5 | import kotlinx.collections.immutable.PersistentSet 6 | import net.simno.dmach.data.Pan 7 | import net.simno.dmach.data.Position 8 | import net.simno.dmach.data.Steps 9 | import net.simno.dmach.data.Swing 10 | import net.simno.dmach.data.Tempo 11 | import net.simno.dmach.settings.Settings 12 | 13 | sealed class Result 14 | 15 | data class ErrorResult( 16 | val error: Throwable 17 | ) : Result() 18 | 19 | data class DebugResult( 20 | val debug: Boolean 21 | ) : Result() 22 | 23 | data class LoadResult( 24 | val title: String, 25 | val sequenceId: Int, 26 | val sequence: PersistentList, 27 | val mutedChannels: PersistentSet, 28 | val selectedChannel: Int, 29 | val selectedSetting: Int, 30 | val settingId: Int, 31 | val settingsSize: Int, 32 | val hText: String, 33 | val vText: String, 34 | val position: Position, 35 | val panId: Int, 36 | val pan: Pan, 37 | val tempo: Tempo, 38 | val swing: Swing, 39 | val steps: Steps 40 | ) : Result() 41 | 42 | data class ResumeResult( 43 | val settingId: Int, 44 | val position: Position, 45 | val panId: Int, 46 | val pan: Pan 47 | ) : Result() 48 | 49 | data class PlaybackResult( 50 | val isPlaying: Boolean 51 | ) : Result() 52 | 53 | data object PlayPauseResult : Result() 54 | 55 | data class SettingsResult( 56 | val settings: Settings 57 | ) : Result() 58 | 59 | data object ChangeSettingsResult : Result() 60 | 61 | data class ConfigResult( 62 | val configId: Int 63 | ) : Result() 64 | 65 | data object ExportResult : Result() 66 | 67 | data class ExportFileResult( 68 | val waveFile: File? 69 | ) : Result() 70 | 71 | data object DismissResult : Result() 72 | 73 | data class ChangeSequenceResult( 74 | val sequenceId: Int, 75 | val sequence: PersistentList 76 | ) : Result() 77 | 78 | data class MuteChannelResult( 79 | val mutedChannels: PersistentSet 80 | ) : Result() 81 | 82 | data class SelectChannelResult( 83 | val selectedChannel: Int, 84 | val selectedSetting: Int, 85 | val settingId: Int, 86 | val settingsSize: Int, 87 | val hText: String, 88 | val vText: String, 89 | val position: Position, 90 | val panId: Int, 91 | val pan: Pan 92 | ) : Result() 93 | 94 | data class SelectSettingResult( 95 | val selectedSetting: Int, 96 | val settingId: Int, 97 | val hText: String, 98 | val vText: String, 99 | val position: Position 100 | ) : Result() 101 | 102 | data object ChangePositionResult : Result() 103 | 104 | data object ChangePanResult : Result() 105 | 106 | data class ChangeTempoResult( 107 | val tempo: Tempo 108 | ) : Result() 109 | 110 | data class ChangeSwingResult( 111 | val swing: Swing 112 | ) : Result() 113 | 114 | data class ChangeStepsResult( 115 | val steps: Steps, 116 | val sequenceId: Int 117 | ) : Result() 118 | 119 | data class ChangePatchResult( 120 | val sequenceId: Int, 121 | val sequence: PersistentList, 122 | val panId: Int, 123 | val pan: Pan, 124 | val settingId: Int, 125 | val position: Position 126 | ) : Result() 127 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/machine/state/ViewState.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.machine.state 2 | 3 | import java.io.File 4 | import kotlinx.collections.immutable.PersistentList 5 | import kotlinx.collections.immutable.PersistentSet 6 | import kotlinx.collections.immutable.persistentSetOf 7 | import net.simno.dmach.data.Channel 8 | import net.simno.dmach.data.Pan 9 | import net.simno.dmach.data.Patch 10 | import net.simno.dmach.data.Position 11 | import net.simno.dmach.data.Steps 12 | import net.simno.dmach.data.Swing 13 | import net.simno.dmach.data.Tempo 14 | import net.simno.dmach.settings.Settings 15 | 16 | data class ViewState( 17 | val debug: Boolean = false, 18 | val title: String = "", 19 | val isPlaying: Boolean = false, 20 | val showConfig: Boolean = false, 21 | val configId: Int = 0, 22 | val showExport: Boolean = false, 23 | val startExport: Boolean = false, 24 | val waveFile: File? = null, 25 | val settings: Settings = Settings(), 26 | val sequenceId: Int = 0, 27 | val sequence: PersistentList = Patch.EMPTY_SEQUENCE, 28 | val mutedChannels: PersistentSet = persistentSetOf(), 29 | val selectedChannel: Int = Channel.NONE_ID, 30 | val selectedSetting: Int = Channel.NONE.selectedSetting, 31 | val settingId: Int = 0, 32 | val settingsSize: Int = Channel.NONE.settings.size, 33 | val hText: String = Channel.NONE.setting.hText, 34 | val vText: String = Channel.NONE.setting.vText, 35 | val position: Position? = Channel.NONE.setting.position, 36 | val panId: Int = 0, 37 | val pan: Pan? = Channel.NONE.pan, 38 | val tempo: Tempo = Tempo(120), 39 | val swing: Swing = Swing(0), 40 | val steps: Steps = Steps(16) 41 | ) 42 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/machine/ui/ChaosPad.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.machine.ui 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.gestures.awaitEachGesture 5 | import androidx.compose.foundation.gestures.awaitFirstDown 6 | import androidx.compose.foundation.layout.Box 7 | import androidx.compose.foundation.layout.fillMaxSize 8 | import androidx.compose.foundation.layout.padding 9 | import androidx.compose.material3.MaterialTheme 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.runtime.LaunchedEffect 12 | import androidx.compose.runtime.getValue 13 | import androidx.compose.runtime.mutableStateOf 14 | import androidx.compose.runtime.remember 15 | import androidx.compose.runtime.rememberUpdatedState 16 | import androidx.compose.runtime.setValue 17 | import androidx.compose.ui.Alignment 18 | import androidx.compose.ui.Modifier 19 | import androidx.compose.ui.draw.clipToBounds 20 | import androidx.compose.ui.draw.drawBehind 21 | import androidx.compose.ui.draw.rotate 22 | import androidx.compose.ui.geometry.Offset 23 | import androidx.compose.ui.graphics.drawscope.Stroke 24 | import androidx.compose.ui.input.pointer.PointerInputChange 25 | import androidx.compose.ui.input.pointer.changedToDown 26 | import androidx.compose.ui.input.pointer.pointerInput 27 | import androidx.compose.ui.input.pointer.positionChanged 28 | import androidx.compose.ui.layout.layout 29 | import androidx.compose.ui.layout.onSizeChanged 30 | import androidx.compose.ui.unit.IntSize 31 | import androidx.compose.ui.unit.dp 32 | import java.util.Locale 33 | import net.simno.dmach.core.DarkLargeText 34 | import net.simno.dmach.core.DarkSmallText 35 | import net.simno.dmach.data.Position 36 | import net.simno.dmach.theme.AppTheme 37 | import net.simno.dmach.util.toPx 38 | 39 | @Composable 40 | fun ChaosPad( 41 | settingId: Int, 42 | position: Position?, 43 | horizontalText: String, 44 | verticalText: String, 45 | debug: Boolean, 46 | onPositionChange: (Position) -> Unit, 47 | modifier: Modifier = Modifier 48 | ) { 49 | val secondary = MaterialTheme.colorScheme.secondary 50 | val shapeSmall = MaterialTheme.shapes.small 51 | val paddingSmall = AppTheme.dimens.paddingSmall 52 | 53 | var debugPosition by remember { mutableStateOf(position) } 54 | 55 | LaunchedEffect(position) { 56 | position?.let { 57 | debugPosition = it 58 | } 59 | } 60 | 61 | Box( 62 | modifier = modifier 63 | .fillMaxSize() 64 | .background( 65 | color = secondary, 66 | shape = shapeSmall 67 | ) 68 | ) { 69 | DarkLargeText( 70 | text = verticalText.uppercase(), 71 | modifier = Modifier 72 | .layout { measurable, constraints -> 73 | val placeable = measurable.measure(constraints) 74 | layout(placeable.height, placeable.width) { 75 | placeable.place( 76 | x = -(placeable.width / 2 - placeable.height / 2), 77 | y = -(placeable.height / 2 - placeable.width / 2) 78 | ) 79 | } 80 | } 81 | .rotate(-90f) 82 | .align(Alignment.CenterStart) 83 | ) 84 | DarkLargeText( 85 | text = horizontalText.uppercase(), 86 | modifier = Modifier 87 | .align(Alignment.BottomCenter) 88 | .padding(bottom = paddingSmall) 89 | ) 90 | if (debug) { 91 | DarkSmallText( 92 | text = debugPosition?.let { String.format(Locale.US, "%.2f", it.y) }.orEmpty(), 93 | modifier = Modifier 94 | .layout { measurable, constraints -> 95 | val placeable = measurable.measure(constraints) 96 | layout(placeable.height, placeable.width) { 97 | placeable.place( 98 | x = -(placeable.width / 2 - placeable.height / 2), 99 | y = -(placeable.height / 2 - placeable.width / 2) 100 | ) 101 | } 102 | } 103 | .rotate(-90f) 104 | .align(Alignment.TopStart) 105 | .padding(end = 4.dp) 106 | ) 107 | DarkSmallText( 108 | text = debugPosition?.let { String.format(Locale.US, "%.2f", it.x) }.orEmpty(), 109 | modifier = Modifier 110 | .align(Alignment.BottomEnd) 111 | .padding(end = 4.dp) 112 | ) 113 | } 114 | Circle( 115 | settingId = settingId, 116 | position = position, 117 | onPositionChange = { 118 | debugPosition = it 119 | onPositionChange(it) 120 | } 121 | ) 122 | } 123 | } 124 | 125 | @Composable 126 | private fun Circle( 127 | settingId: Int, 128 | position: Position?, 129 | onPositionChange: (Position) -> Unit, 130 | modifier: Modifier = Modifier 131 | ) { 132 | val currentOnPositionChange by rememberUpdatedState(onPositionChange) 133 | 134 | val surface = MaterialTheme.colorScheme.surface 135 | val radius = AppTheme.dimens.circleRadius.toPx() 136 | val strokeWidth = AppTheme.dimens.paddingSmall.toPx() 137 | 138 | var size by remember { mutableStateOf(IntSize.Zero) } 139 | 140 | val stroke = remember(strokeWidth) { Stroke(width = strokeWidth) } 141 | val minX = remember(strokeWidth, radius) { strokeWidth / 2f + radius } 142 | val minY = remember(strokeWidth, radius) { strokeWidth / 2f + radius } 143 | val maxX = remember(minX, size.width) { size.width - minX } 144 | val maxY = remember(minY, size.height) { size.height - minY } 145 | 146 | var circlePosition by remember(settingId, maxX, maxY) { 147 | mutableStateOf( 148 | position?.let { 149 | // Convert position value [0.0-1.0] to pixels. 150 | val newX = it.x * ((maxX - minX) + minX) 151 | val newY = (1 - it.y) * ((maxY - minY) + minY) 152 | Position(newX, newY) 153 | } 154 | ) 155 | } 156 | 157 | val circleOffset = remember(circlePosition) { 158 | circlePosition?.let { 159 | val newX = it.x 160 | .coerceAtLeast(minX) 161 | .coerceAtMost(maxX) 162 | val newY = it.y 163 | .coerceAtLeast(minY) 164 | .coerceAtMost(maxY) 165 | Offset(newX, newY) 166 | } 167 | } 168 | 169 | Box( 170 | modifier = modifier 171 | .fillMaxSize() 172 | .clipToBounds() 173 | .onSizeChanged { size = it } 174 | .pointerInput(settingId, size) { 175 | fun notifyPosition(x: Float, y: Float) { 176 | // Convert pixels to a position value [0.0-1.0] 177 | val posX = ((x - minX) / (maxX - minX)).coerceIn(0f, 1f) 178 | val posY = 1 - ((y - minY) / (maxY - minY)).coerceIn(0f, 1f) 179 | currentOnPositionChange(Position(posX, posY)) 180 | } 181 | 182 | fun onPointerDownOrMove(pointer: PointerInputChange) { 183 | circlePosition = Position(pointer.position.x, pointer.position.y) 184 | notifyPosition(pointer.position.x, pointer.position.y) 185 | } 186 | 187 | awaitEachGesture { 188 | val firstPointer = awaitFirstDown() 189 | if (firstPointer.changedToDown()) { 190 | firstPointer.consume() 191 | } 192 | onPointerDownOrMove(firstPointer) 193 | 194 | do { 195 | val event = awaitPointerEvent() 196 | event.changes.forEach { pointer -> 197 | if (pointer.positionChanged()) { 198 | pointer.consume() 199 | } 200 | onPointerDownOrMove(pointer) 201 | } 202 | } while (event.changes.any { it.pressed }) 203 | } 204 | } 205 | .drawBehind { 206 | circleOffset?.let { 207 | drawCircle( 208 | color = surface, 209 | radius = radius, 210 | style = stroke, 211 | center = it, 212 | alpha = 0.94f 213 | ) 214 | } 215 | } 216 | ) 217 | } 218 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/machine/ui/ConfigCheckbox.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.machine.ui 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.Arrangement 5 | import androidx.compose.foundation.layout.Row 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.material3.Checkbox 10 | import androidx.compose.material3.CheckboxDefaults 11 | import androidx.compose.material3.MaterialTheme 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.ui.Alignment 14 | import androidx.compose.ui.Modifier 15 | import net.simno.dmach.core.LightSmallText 16 | import net.simno.dmach.theme.AppTheme 17 | 18 | @Composable 19 | fun ConfigCheckbox( 20 | text: String, 21 | checked: Boolean, 22 | onCheckedChange: (Boolean) -> Unit, 23 | modifier: Modifier = Modifier 24 | ) { 25 | val surface = MaterialTheme.colorScheme.surface 26 | val onPrimary = MaterialTheme.colorScheme.onPrimary 27 | val shapeMedium = MaterialTheme.shapes.medium 28 | val paddingLarge = AppTheme.dimens.paddingLarge 29 | val paddingSmall = AppTheme.dimens.paddingSmall 30 | val configHeightSmall = AppTheme.dimens.configHeightSmall 31 | 32 | Row( 33 | modifier = modifier 34 | .background( 35 | color = onPrimary, 36 | shape = shapeMedium 37 | ) 38 | .fillMaxWidth() 39 | .height(configHeightSmall) 40 | .padding( 41 | start = paddingLarge, 42 | top = paddingSmall, 43 | end = paddingSmall, 44 | bottom = paddingSmall 45 | ), 46 | verticalAlignment = Alignment.CenterVertically, 47 | horizontalArrangement = Arrangement.SpaceBetween 48 | ) { 49 | LightSmallText(text = text) 50 | Checkbox( 51 | checked = checked, 52 | onCheckedChange = onCheckedChange, 53 | colors = CheckboxDefaults.colors( 54 | checkedColor = surface, 55 | uncheckedColor = surface, 56 | checkmarkColor = onPrimary 57 | ) 58 | ) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/machine/ui/ConfigDialog.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.machine.ui 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.foundation.layout.Arrangement 6 | import androidx.compose.foundation.layout.Column 7 | import androidx.compose.foundation.layout.IntrinsicSize 8 | import androidx.compose.foundation.layout.Row 9 | import androidx.compose.foundation.layout.fillMaxWidth 10 | import androidx.compose.foundation.layout.height 11 | import androidx.compose.foundation.layout.padding 12 | import androidx.compose.foundation.layout.size 13 | import androidx.compose.foundation.layout.width 14 | import androidx.compose.material.icons.Icons 15 | import androidx.compose.material.icons.filled.ChevronLeft 16 | import androidx.compose.material.icons.filled.ChevronRight 17 | import androidx.compose.material3.Icon 18 | import androidx.compose.material3.MaterialTheme 19 | import androidx.compose.runtime.Composable 20 | import androidx.compose.runtime.getValue 21 | import androidx.compose.runtime.mutableFloatStateOf 22 | import androidx.compose.runtime.mutableStateOf 23 | import androidx.compose.runtime.remember 24 | import androidx.compose.runtime.setValue 25 | import androidx.compose.ui.Alignment 26 | import androidx.compose.ui.Modifier 27 | import androidx.compose.ui.draw.scale 28 | import androidx.compose.ui.res.stringResource 29 | import androidx.compose.ui.unit.dp 30 | import androidx.compose.ui.window.Dialog 31 | import androidx.compose.ui.window.DialogProperties 32 | import net.simno.dmach.R 33 | import net.simno.dmach.core.LightMediumLabel 34 | import net.simno.dmach.core.PredictiveBackProgress 35 | import net.simno.dmach.data.Steps 36 | import net.simno.dmach.data.Swing 37 | import net.simno.dmach.data.Tempo 38 | import net.simno.dmach.settings.Settings 39 | import net.simno.dmach.theme.AppTheme 40 | 41 | @Composable 42 | fun ConfigDialog( 43 | configId: Int, 44 | tempo: Tempo, 45 | swing: Swing, 46 | steps: Steps, 47 | settings: Settings, 48 | onTempoChange: (Tempo) -> Unit, 49 | onSwingChange: (Swing) -> Unit, 50 | onStepsChange: (Steps) -> Unit, 51 | onSettingsChange: (Settings) -> Unit, 52 | modifier: Modifier = Modifier, 53 | onDismiss: () -> Unit 54 | ) { 55 | val surface = MaterialTheme.colorScheme.surface 56 | val primary = MaterialTheme.colorScheme.primary 57 | val onPrimary = MaterialTheme.colorScheme.onPrimary 58 | val shapeMedium = MaterialTheme.shapes.medium 59 | val paddingSmall = AppTheme.dimens.paddingSmall 60 | val paddingLarge = AppTheme.dimens.paddingLarge 61 | val configHeightSmall = AppTheme.dimens.configHeightSmall 62 | var showSettings by remember { mutableStateOf(false) } 63 | 64 | Dialog( 65 | properties = DialogProperties(usePlatformDefaultWidth = showSettings), 66 | onDismissRequest = onDismiss 67 | ) { 68 | var backProgress by remember { mutableFloatStateOf(0f) } 69 | var inPredictiveBack by remember { mutableStateOf(false) } 70 | PredictiveBackProgress( 71 | onProgress = { backProgress = it }, 72 | onInPredictiveBack = { inPredictiveBack = it }, 73 | onBack = onDismiss 74 | ) 75 | 76 | Column( 77 | modifier = modifier 78 | .scale((1f - backProgress).coerceAtLeast(0.85f)) 79 | .background( 80 | color = primary, 81 | shape = shapeMedium 82 | ) 83 | .width(IntrinsicSize.Max) 84 | .padding(paddingSmall), 85 | verticalArrangement = Arrangement.spacedBy(paddingSmall) 86 | ) { 87 | if (showSettings) { 88 | Row( 89 | modifier = Modifier 90 | .background( 91 | color = onPrimary, 92 | shape = shapeMedium 93 | ) 94 | .fillMaxWidth() 95 | .height(configHeightSmall) 96 | .clickable(onClick = { showSettings = false }) 97 | .padding(horizontal = paddingLarge), 98 | verticalAlignment = Alignment.CenterVertically, 99 | horizontalArrangement = Arrangement.spacedBy(16.dp) 100 | ) { 101 | Icon( 102 | imageVector = Icons.Filled.ChevronLeft, 103 | contentDescription = null, 104 | tint = surface, 105 | modifier = Modifier.size(24.dp) 106 | ) 107 | LightMediumLabel(stringResource(R.string.back)) 108 | } 109 | ConfigCheckbox( 110 | text = stringResource(R.string.audiofocus), 111 | checked = settings.ignoreAudioFocus, 112 | onCheckedChange = { checked -> 113 | onSettingsChange(settings.copy(ignoreAudioFocus = checked)) 114 | } 115 | ) 116 | ConfigCheckbox( 117 | text = stringResource(R.string.sequencer_setting), 118 | checked = settings.sequenceEnabled, 119 | onCheckedChange = { checked -> 120 | onSettingsChange(settings.copy(sequenceEnabled = checked)) 121 | } 122 | ) 123 | ConfigCheckbox( 124 | text = stringResource(R.string.sound_setting), 125 | checked = settings.soundEnabled, 126 | onCheckedChange = { checked -> 127 | onSettingsChange(settings.copy(soundEnabled = checked)) 128 | } 129 | ) 130 | ConfigCheckbox( 131 | text = stringResource(R.string.pan_setting), 132 | checked = settings.panEnabled, 133 | onCheckedChange = { checked -> 134 | onSettingsChange(settings.copy(panEnabled = checked)) 135 | } 136 | ) 137 | } else { 138 | ConfigValue( 139 | configId = configId, 140 | label = R.string.bpm, 141 | configValue = tempo.value, 142 | minValue = 1, 143 | maxValue = 1000, 144 | onValueChange = { value -> onTempoChange(Tempo(value)) } 145 | ) 146 | ConfigValue( 147 | configId = configId, 148 | label = R.string.swing, 149 | configValue = swing.value, 150 | minValue = 0, 151 | maxValue = 50, 152 | onValueChange = { value -> onSwingChange(Swing(value)) } 153 | ) 154 | ConfigValue( 155 | configId = configId, 156 | label = R.string.steps, 157 | configValue = steps.value, 158 | minValue = 8, 159 | maxValue = 16, 160 | onValueChange = { value -> onStepsChange(Steps(value)) } 161 | ) 162 | Row( 163 | modifier = Modifier 164 | .background( 165 | color = onPrimary, 166 | shape = shapeMedium 167 | ) 168 | .fillMaxWidth() 169 | .height(configHeightSmall) 170 | .clickable(onClick = { showSettings = true }) 171 | .padding(horizontal = paddingLarge), 172 | verticalAlignment = Alignment.CenterVertically, 173 | horizontalArrangement = Arrangement.SpaceBetween 174 | ) { 175 | LightMediumLabel(stringResource(R.string.more_settings)) 176 | Icon( 177 | imageVector = Icons.Filled.ChevronRight, 178 | contentDescription = null, 179 | tint = surface, 180 | modifier = Modifier.size(24.dp) 181 | ) 182 | } 183 | } 184 | } 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/machine/ui/ConfigValue.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.machine.ui 2 | 3 | import androidx.annotation.StringRes 4 | import androidx.compose.foundation.background 5 | import androidx.compose.foundation.gestures.awaitEachGesture 6 | import androidx.compose.foundation.gestures.awaitFirstDown 7 | import androidx.compose.foundation.layout.Arrangement 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.defaultMinSize 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.material.icons.Icons 17 | import androidx.compose.material.icons.filled.Add 18 | import androidx.compose.material.icons.filled.Remove 19 | import androidx.compose.material3.Icon 20 | import androidx.compose.material3.MaterialTheme 21 | import androidx.compose.runtime.Composable 22 | import androidx.compose.runtime.getValue 23 | import androidx.compose.runtime.mutableIntStateOf 24 | import androidx.compose.runtime.remember 25 | import androidx.compose.runtime.rememberUpdatedState 26 | import androidx.compose.runtime.setValue 27 | import androidx.compose.ui.Alignment 28 | import androidx.compose.ui.Modifier 29 | import androidx.compose.ui.input.pointer.changedToDown 30 | import androidx.compose.ui.input.pointer.pointerInput 31 | import androidx.compose.ui.input.pointer.positionChanged 32 | import androidx.compose.ui.res.stringResource 33 | import androidx.compose.ui.unit.dp 34 | import kotlinx.coroutines.CoroutineStart 35 | import kotlinx.coroutines.coroutineScope 36 | import kotlinx.coroutines.delay 37 | import kotlinx.coroutines.isActive 38 | import kotlinx.coroutines.launch 39 | import net.simno.dmach.core.LightLargeText 40 | import net.simno.dmach.core.LightMediumLabel 41 | import net.simno.dmach.theme.AppTheme 42 | 43 | @Composable 44 | fun ConfigValue( 45 | configId: Int, 46 | @StringRes label: Int, 47 | configValue: Int, 48 | minValue: Int, 49 | maxValue: Int, 50 | onValueChange: (Int) -> Unit, 51 | modifier: Modifier = Modifier 52 | ) { 53 | val surface = MaterialTheme.colorScheme.surface 54 | val onPrimary = MaterialTheme.colorScheme.onPrimary 55 | val shapeMedium = MaterialTheme.shapes.medium 56 | val paddingLarge = AppTheme.dimens.paddingLarge 57 | val configHeight = AppTheme.dimens.configHeight 58 | val currentOnValueChange by rememberUpdatedState(onValueChange) 59 | var value by remember { mutableIntStateOf(configValue) } 60 | 61 | Row( 62 | modifier = modifier 63 | .background( 64 | color = onPrimary, 65 | shape = shapeMedium 66 | ) 67 | .fillMaxWidth() 68 | .height(configHeight) 69 | .padding(horizontal = paddingLarge) 70 | .pointerInput(configId) { 71 | val width = size.width.toFloat() 72 | val center = width / 2f 73 | value = configValue 74 | .coerceAtLeast(minValue) 75 | .coerceAtMost(maxValue) 76 | var delay = 500L 77 | var change = 1 78 | 79 | fun calculateChangeAndDelay(x: Float) { 80 | val validX = x.coerceIn(0f, width) 81 | 82 | change = if (validX > center) 1 else -1 83 | 84 | val delayMultiplier = if (validX <= center) { 85 | validX / center 86 | } else { 87 | 1 - ((validX - center) / (width - center)) 88 | } 89 | 90 | delay = (((MAX_DELAY - MIN_DELAY) * delayMultiplier) + MIN_DELAY).toLong() 91 | } 92 | 93 | coroutineScope { 94 | awaitEachGesture { 95 | val job = launch(start = CoroutineStart.LAZY) { 96 | runCatching { 97 | while (isActive) { 98 | val newValue = value + change 99 | if (newValue in minValue..maxValue) { 100 | value = newValue 101 | currentOnValueChange(newValue) 102 | } 103 | delay(delay) 104 | } 105 | } 106 | } 107 | 108 | val firstPointer = awaitFirstDown() 109 | if (firstPointer.changedToDown()) { 110 | firstPointer.consume() 111 | } 112 | calculateChangeAndDelay(firstPointer.position.x) 113 | job.start() 114 | 115 | do { 116 | val event = awaitPointerEvent() 117 | event.changes.forEach { pointer -> 118 | if (pointer.positionChanged()) { 119 | pointer.consume() 120 | } 121 | calculateChangeAndDelay(pointer.position.x) 122 | } 123 | } while (event.changes.any { it.pressed }) 124 | 125 | job.cancel() 126 | } 127 | } 128 | }, 129 | horizontalArrangement = Arrangement.SpaceBetween 130 | ) { 131 | Icon( 132 | imageVector = Icons.Filled.Remove, 133 | contentDescription = null, 134 | tint = surface, 135 | modifier = Modifier 136 | .align(Alignment.CenterVertically) 137 | .size(36.dp) 138 | ) 139 | Spacer(modifier = Modifier.size(96.dp)) 140 | Icon( 141 | imageVector = Icons.Filled.Remove, 142 | contentDescription = null, 143 | tint = surface, 144 | modifier = Modifier 145 | .align(Alignment.CenterVertically) 146 | .size(18.dp) 147 | ) 148 | Column( 149 | modifier = Modifier 150 | .align(Alignment.CenterVertically) 151 | .defaultMinSize(minWidth = 64.dp) 152 | ) { 153 | LightMediumLabel( 154 | text = stringResource(label).uppercase(), 155 | modifier = Modifier.align(Alignment.CenterHorizontally) 156 | ) 157 | LightLargeText( 158 | text = "$value", 159 | modifier = Modifier.align(Alignment.CenterHorizontally) 160 | ) 161 | } 162 | Icon( 163 | imageVector = Icons.Filled.Add, 164 | contentDescription = null, 165 | tint = surface, 166 | modifier = Modifier 167 | .align(Alignment.CenterVertically) 168 | .size(18.dp) 169 | ) 170 | Spacer(modifier = Modifier.size(96.dp)) 171 | Icon( 172 | imageVector = Icons.Filled.Add, 173 | contentDescription = null, 174 | tint = surface, 175 | modifier = Modifier 176 | .align(Alignment.CenterVertically) 177 | .size(36.dp) 178 | ) 179 | } 180 | } 181 | 182 | private const val MAX_DELAY = 400f 183 | private const val MIN_DELAY = 32f 184 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/machine/ui/ExportDialog.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.machine.ui 2 | 3 | import android.content.Intent 4 | import androidx.activity.compose.rememberLauncherForActivityResult 5 | import androidx.activity.result.contract.ActivityResultContracts.CreateDocument 6 | import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.runtime.rememberCoroutineScope 9 | import androidx.compose.ui.platform.LocalContext 10 | import androidx.compose.ui.res.stringResource 11 | import androidx.compose.ui.window.DialogProperties 12 | import androidx.core.content.FileProvider 13 | import java.io.File 14 | import kotlinx.coroutines.Dispatchers 15 | import kotlinx.coroutines.launch 16 | import net.simno.dmach.BuildConfig 17 | import net.simno.dmach.R 18 | import net.simno.dmach.core.OptionsDialog 19 | 20 | private const val WAV_MIME_TYPE = "audio/x-wav" 21 | 22 | @Composable 23 | fun ExportDialog( 24 | enabled: Boolean, 25 | waveFile: File?, 26 | onDismiss: () -> Unit 27 | ) { 28 | val context = LocalContext.current 29 | val scope = rememberCoroutineScope() 30 | 31 | val shareLauncher = rememberLauncherForActivityResult(StartActivityForResult()) { 32 | } 33 | 34 | val saveLauncher = rememberLauncherForActivityResult(CreateDocument(WAV_MIME_TYPE)) { uri -> 35 | if (uri != null && waveFile != null) { 36 | scope.launch(Dispatchers.IO) { 37 | runCatching { 38 | context.contentResolver.openOutputStream(uri)?.use { stream -> 39 | stream.write(waveFile.readBytes()) 40 | } 41 | } 42 | } 43 | } 44 | } 45 | 46 | OptionsDialog( 47 | text = stringResource(R.string.export_wave_file), 48 | option1Text = stringResource(R.string.save), 49 | option2Text = stringResource(R.string.share), 50 | onDismiss = { 51 | runCatching { waveFile?.delete() } 52 | onDismiss() 53 | }, 54 | onOption1 = { 55 | waveFile?.name?.let { name -> saveLauncher.launch(name) } 56 | }, 57 | onOption2 = { 58 | waveFile?.let { file -> 59 | val uri = FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID, file) 60 | val target = Intent(Intent.ACTION_SEND).apply { 61 | setDataAndType(uri, WAV_MIME_TYPE) 62 | putExtra(Intent.EXTRA_STREAM, uri) 63 | putExtra(Intent.EXTRA_SUBJECT, file.name) 64 | addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) 65 | } 66 | val intent = Intent.createChooser(target, context.getString(R.string.share)) 67 | shareLauncher.launch(intent) 68 | } 69 | }, 70 | enabled = enabled, 71 | properties = DialogProperties( 72 | dismissOnBackPress = enabled, 73 | dismissOnClickOutside = enabled 74 | ) 75 | ) 76 | } 77 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/machine/ui/IconButton.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.machine.ui 2 | 3 | import androidx.annotation.StringRes 4 | import androidx.compose.foundation.background 5 | import androidx.compose.foundation.combinedClickable 6 | import androidx.compose.foundation.interaction.MutableInteractionSource 7 | import androidx.compose.foundation.interaction.collectIsPressedAsState 8 | import androidx.compose.foundation.layout.Box 9 | import androidx.compose.foundation.layout.fillMaxSize 10 | import androidx.compose.foundation.layout.padding 11 | import androidx.compose.material3.Icon 12 | import androidx.compose.material3.MaterialTheme 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.runtime.getValue 15 | import androidx.compose.runtime.remember 16 | import androidx.compose.ui.Alignment 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.graphics.vector.ImageVector 19 | import androidx.compose.ui.res.stringResource 20 | import androidx.compose.ui.semantics.Role 21 | import androidx.compose.ui.unit.Dp 22 | import androidx.lifecycle.compose.dropUnlessResumed 23 | import net.simno.dmach.core.hapticClick 24 | import net.simno.dmach.theme.AppTheme 25 | 26 | @Composable 27 | fun IconButton( 28 | icon: ImageVector, 29 | @StringRes description: Int, 30 | selected: Boolean, 31 | onClick: () -> Unit, 32 | modifier: Modifier = Modifier, 33 | iconPadding: Dp = AppTheme.dimens.paddingLarge, 34 | onLongClick: (() -> Unit)? = null 35 | ) { 36 | val interactionSource = remember { MutableInteractionSource() } 37 | val pressed by interactionSource.collectIsPressedAsState() 38 | val background = when { 39 | pressed -> MaterialTheme.colorScheme.onSecondary 40 | selected -> MaterialTheme.colorScheme.onSecondary 41 | else -> MaterialTheme.colorScheme.primary 42 | } 43 | Box( 44 | modifier = modifier 45 | .fillMaxSize() 46 | .background( 47 | color = background, 48 | shape = MaterialTheme.shapes.medium 49 | ) 50 | .combinedClickable( 51 | onClick = dropUnlessResumed(block = onClick), 52 | onLongClick = hapticClick(block = onLongClick), 53 | enabled = true, 54 | role = Role.Button, 55 | interactionSource = interactionSource, 56 | indication = null 57 | ), 58 | contentAlignment = Alignment.Center 59 | ) { 60 | Icon( 61 | imageVector = icon, 62 | contentDescription = stringResource(description), 63 | tint = MaterialTheme.colorScheme.surface, 64 | modifier = Modifier 65 | .fillMaxSize() 66 | .padding(iconPadding) 67 | ) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/machine/ui/PanFader.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.machine.ui 2 | 3 | import android.animation.ValueAnimator 4 | import android.view.animation.DecelerateInterpolator 5 | import androidx.compose.foundation.gestures.awaitEachGesture 6 | import androidx.compose.foundation.gestures.awaitFirstDown 7 | import androidx.compose.foundation.layout.Box 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.height 12 | import androidx.compose.foundation.layout.padding 13 | import androidx.compose.foundation.layout.width 14 | import androidx.compose.foundation.layout.wrapContentSize 15 | import androidx.compose.material3.MaterialTheme 16 | import androidx.compose.runtime.Composable 17 | import androidx.compose.runtime.LaunchedEffect 18 | import androidx.compose.runtime.getValue 19 | import androidx.compose.runtime.mutableStateOf 20 | import androidx.compose.runtime.remember 21 | import androidx.compose.runtime.rememberUpdatedState 22 | import androidx.compose.runtime.setValue 23 | import androidx.compose.ui.Alignment 24 | import androidx.compose.ui.Modifier 25 | import androidx.compose.ui.draw.clipToBounds 26 | import androidx.compose.ui.draw.drawBehind 27 | import androidx.compose.ui.geometry.CornerRadius 28 | import androidx.compose.ui.geometry.Offset 29 | import androidx.compose.ui.geometry.Size 30 | import androidx.compose.ui.graphics.drawscope.Stroke 31 | import androidx.compose.ui.hapticfeedback.HapticFeedbackType 32 | import androidx.compose.ui.input.pointer.changedToDown 33 | import androidx.compose.ui.input.pointer.pointerInput 34 | import androidx.compose.ui.input.pointer.positionChanged 35 | import androidx.compose.ui.layout.onSizeChanged 36 | import androidx.compose.ui.platform.LocalDensity 37 | import androidx.compose.ui.platform.LocalHapticFeedback 38 | import androidx.compose.ui.res.stringResource 39 | import androidx.compose.ui.text.style.TextAlign 40 | import androidx.compose.ui.unit.IntSize 41 | import androidx.compose.ui.unit.dp 42 | import java.util.Locale 43 | import net.simno.dmach.R 44 | import net.simno.dmach.core.DarkLargeText 45 | import net.simno.dmach.core.DarkSmallText 46 | import net.simno.dmach.data.Pan 47 | import net.simno.dmach.theme.AppTheme 48 | import net.simno.dmach.util.toPx 49 | 50 | @Composable 51 | fun PanFader( 52 | panId: Int, 53 | pan: Pan?, 54 | debug: Boolean, 55 | onPanChange: (Pan) -> Unit, 56 | modifier: Modifier = Modifier 57 | ) { 58 | val rectHeight = AppTheme.dimens.rectHeight 59 | val buttonMedium = AppTheme.dimens.buttonMedium 60 | 61 | var debugPan by remember { mutableStateOf(pan) } 62 | 63 | LaunchedEffect(pan) { 64 | pan?.let { 65 | debugPan = it 66 | } 67 | } 68 | 69 | Box( 70 | modifier = modifier 71 | .width(buttonMedium) 72 | .fillMaxHeight() 73 | ) { 74 | Box( 75 | modifier = Modifier 76 | .fillMaxWidth() 77 | .height(rectHeight) 78 | .align(Alignment.TopCenter) 79 | .padding(top = 2.dp) 80 | ) { 81 | DarkLargeText( 82 | text = stringResource(R.string.pan_right), 83 | textAlign = TextAlign.Center, 84 | modifier = Modifier 85 | .wrapContentSize() 86 | .align(Alignment.Center) 87 | ) 88 | } 89 | Box( 90 | modifier = Modifier 91 | .fillMaxWidth() 92 | .height(rectHeight) 93 | .align(Alignment.BottomCenter) 94 | .padding(bottom = 2.dp) 95 | ) { 96 | DarkLargeText( 97 | text = stringResource(R.string.pan_left), 98 | textAlign = TextAlign.Center, 99 | modifier = Modifier 100 | .wrapContentSize() 101 | .align(Alignment.Center) 102 | ) 103 | } 104 | if (debug) { 105 | DarkSmallText( 106 | text = debugPan?.let { String.format(Locale.US, "%.2f", it.value) }.orEmpty(), 107 | modifier = Modifier.align(Alignment.Center) 108 | ) 109 | } 110 | Fader( 111 | panId = panId, 112 | pan = pan, 113 | onPanChange = { 114 | debugPan = it 115 | onPanChange(it) 116 | } 117 | ) 118 | } 119 | } 120 | 121 | @Composable 122 | private fun Fader( 123 | panId: Int, 124 | pan: Pan?, 125 | onPanChange: (Pan) -> Unit, 126 | modifier: Modifier = Modifier 127 | ) { 128 | val currentOnPanChange by rememberUpdatedState(onPanChange) 129 | 130 | val haptic = LocalHapticFeedback.current 131 | val density = LocalDensity.current 132 | val secondary = MaterialTheme.colorScheme.secondary 133 | val cornerSize = MaterialTheme.shapes.small.topStart 134 | val rectHeight = AppTheme.dimens.rectHeight.toPx() 135 | val strokeWidth = AppTheme.dimens.paddingSmall.toPx() 136 | 137 | var size by remember { mutableStateOf(IntSize.Zero) } 138 | 139 | val rectSize = remember(strokeWidth, size.width, rectHeight) { 140 | Size(size.width.toFloat() - strokeWidth, rectHeight) 141 | } 142 | val offset = remember(rectHeight) { rectHeight / 2f } 143 | val cornerRadius = remember(rectSize) { cornerSize.toPx(rectSize, density).let { CornerRadius(it, it) } } 144 | val stroke = remember(strokeWidth) { Stroke(width = strokeWidth) } 145 | val minX = remember(strokeWidth) { strokeWidth / 2f } 146 | val minY = remember(strokeWidth, offset) { offset + (strokeWidth / 2f) } 147 | val maxY = remember(minY, size.height) { size.height - minY } 148 | 149 | var panPosition by remember(panId, maxY) { 150 | mutableStateOf( 151 | pan?.let { 152 | // Convert position value [0.0-1.0] to pixels. 153 | if (pan.value == 0.5f) { 154 | size.height / 2f 155 | } else { 156 | (1 - pan.value) * ((maxY - minY) + minY) 157 | } 158 | } 159 | ) 160 | } 161 | 162 | val panOffset = remember(panPosition, offset, minX) { 163 | panPosition?.let { 164 | val newY = it 165 | .coerceAtLeast(minY) 166 | .coerceAtMost(maxY) 167 | Offset(minX, newY - offset) 168 | } 169 | } 170 | 171 | Box( 172 | modifier = modifier 173 | .fillMaxSize() 174 | .clipToBounds() 175 | .onSizeChanged { size = it } 176 | .pointerInput(panId, size) { 177 | var centerAnimator: ValueAnimator? = null 178 | var isCentered = true 179 | val center = size.height / 2f 180 | val left = center + (offset / 2f) 181 | val right = center - (offset / 2f) 182 | 183 | fun notifyPosition(y: Float, notifyCenter: Boolean = false) { 184 | // Convert pixels to a position value [0.0-1.0] 185 | val pos = if (notifyCenter) { 186 | // Pixel conversion is not exact. Set y to 0.5 if we know it is centered. 187 | 0.5f 188 | } else { 189 | 1 - ((y - minY) / (maxY - minY)).coerceIn(0f, 1f) 190 | } 191 | currentOnPanChange(Pan(pos)) 192 | } 193 | 194 | fun animateToCenter(y: Float, center: Float) { 195 | centerAnimator?.cancel() 196 | centerAnimator = ValueAnimator 197 | .ofFloat(y, center) 198 | .apply { 199 | duration = 100L 200 | interpolator = DecelerateInterpolator() 201 | addUpdateListener { animation -> 202 | (animation.animatedValue as Float).let { 203 | panPosition = it 204 | notifyPosition(it, notifyCenter = it == center) 205 | } 206 | } 207 | start() 208 | } 209 | haptic.performHapticFeedback(HapticFeedbackType.LongPress) 210 | } 211 | 212 | fun onPointerDownOrMove(y: Float) { 213 | if (y < left && y > right) { 214 | if (!isCentered) { 215 | isCentered = true 216 | animateToCenter(y, center) 217 | } 218 | } else { 219 | isCentered = false 220 | panPosition = y 221 | notifyPosition(y) 222 | } 223 | } 224 | 225 | awaitEachGesture { 226 | val firstPointer = awaitFirstDown() 227 | if (firstPointer.changedToDown()) { 228 | firstPointer.consume() 229 | } 230 | onPointerDownOrMove(firstPointer.position.y) 231 | 232 | do { 233 | val event = awaitPointerEvent() 234 | event.changes.forEach { pointer -> 235 | if (pointer.positionChanged()) { 236 | pointer.consume() 237 | } 238 | onPointerDownOrMove(pointer.position.y) 239 | } 240 | } while (event.changes.any { it.pressed }) 241 | } 242 | } 243 | .drawBehind { 244 | panOffset?.let { 245 | drawRoundRect( 246 | color = secondary, 247 | topLeft = it, 248 | size = rectSize, 249 | cornerRadius = cornerRadius, 250 | style = stroke, 251 | alpha = 0.94f 252 | ) 253 | } 254 | } 255 | ) 256 | } 257 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/machine/ui/StepSequencer.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.machine.ui 2 | 3 | import androidx.compose.foundation.gestures.awaitEachGesture 4 | import androidx.compose.foundation.gestures.awaitFirstDown 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.fillMaxSize 7 | import androidx.compose.material3.MaterialTheme 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.runtime.getValue 10 | import androidx.compose.runtime.mutableIntStateOf 11 | import androidx.compose.runtime.mutableStateListOf 12 | import androidx.compose.runtime.mutableStateOf 13 | import androidx.compose.runtime.remember 14 | import androidx.compose.runtime.rememberUpdatedState 15 | import androidx.compose.runtime.setValue 16 | import androidx.compose.ui.Modifier 17 | import androidx.compose.ui.draw.clipToBounds 18 | import androidx.compose.ui.draw.drawBehind 19 | import androidx.compose.ui.geometry.CornerRadius 20 | import androidx.compose.ui.geometry.Offset 21 | import androidx.compose.ui.geometry.Size 22 | import androidx.compose.ui.graphics.Color 23 | import androidx.compose.ui.graphics.drawscope.Fill 24 | import androidx.compose.ui.input.pointer.changedToDown 25 | import androidx.compose.ui.input.pointer.pointerInput 26 | import androidx.compose.ui.input.pointer.positionChanged 27 | import androidx.compose.ui.layout.onSizeChanged 28 | import androidx.compose.ui.platform.LocalDensity 29 | import androidx.compose.ui.unit.IntSize 30 | import kotlinx.collections.immutable.PersistentList 31 | import kotlinx.collections.immutable.toPersistentList 32 | import net.simno.dmach.data.Patch.Companion.CHANNELS 33 | import net.simno.dmach.data.Patch.Companion.MASKS 34 | import net.simno.dmach.data.Patch.Companion.STEPS 35 | import net.simno.dmach.data.Steps 36 | import net.simno.dmach.theme.AppTheme 37 | import net.simno.dmach.util.toPx 38 | 39 | @Composable 40 | fun StepSequencer( 41 | sequenceId: Int, 42 | sequence: PersistentList, 43 | sequenceLength: Steps, 44 | onSequenceChange: (PersistentList) -> Unit, 45 | modifier: Modifier = Modifier 46 | ) { 47 | val currentOnSequenceChange by rememberUpdatedState(onSequenceChange) 48 | 49 | val density = LocalDensity.current 50 | val tertiary = MaterialTheme.colorScheme.tertiary 51 | val onSurface = MaterialTheme.colorScheme.onSurface 52 | val onSurfaceVariant = MaterialTheme.colorScheme.onSurfaceVariant 53 | val cornerSize = MaterialTheme.shapes.small.topStart 54 | val margin = AppTheme.dimens.paddingSmall.toPx() 55 | 56 | var size by remember { mutableStateOf(IntSize.Zero) } 57 | 58 | var stepChanges by remember { mutableIntStateOf(0) } 59 | val steps = remember(sequenceId) { mutableStateListOf(*sequence.toTypedArray()) } 60 | 61 | val stepSize = remember(size, margin, sequenceLength) { 62 | Size( 63 | width = (size.width - (sequenceLength.value - 1f) * margin) / sequenceLength.value, 64 | height = (size.height - (CHANNELS - 1f) * margin) / CHANNELS 65 | ) 66 | } 67 | 68 | val cornerRadius = remember(stepSize) { cornerSize.toPx(stepSize, density).let { CornerRadius(it, it) } } 69 | 70 | val drawableSteps = remember(steps, stepChanges, stepSize) { 71 | steps 72 | .mapIndexed { stepIndex, step -> 73 | MASKS.mapIndexed { maskIndex, mask -> 74 | val left = (stepIndex % STEPS) * (stepSize.width + margin) 75 | val top = (maskIndex + ((stepIndex / STEPS) * MASKS.size)) * (stepSize.height + margin) 76 | val color = when { 77 | step and mask > 0 -> tertiary 78 | stepIndex % 8 < 4 -> onSurface 79 | else -> onSurfaceVariant 80 | } 81 | DrawableStep( 82 | color = color, 83 | offset = Offset(left, top) 84 | ) 85 | } 86 | } 87 | .flatten() 88 | } 89 | 90 | Box( 91 | modifier = modifier 92 | .fillMaxSize() 93 | .onSizeChanged { size = it } 94 | .clipToBounds() 95 | .pointerInput(sequenceId, size) { 96 | fun onStepChange(stepChange: StepChange) { 97 | steps[stepChange.index] = stepChange.changedStep 98 | stepChanges++ 99 | currentOnSequenceChange(steps.toPersistentList()) 100 | } 101 | 102 | awaitEachGesture { 103 | val firstPointer = awaitFirstDown() 104 | if (firstPointer.changedToDown()) { 105 | firstPointer.consume() 106 | } 107 | 108 | val firstStepChange = firstPointer.position 109 | .takeIf { it.isValid(size) } 110 | ?.let { position -> 111 | StepChange(steps, stepSize, margin, position) 112 | } 113 | firstStepChange?.let(::onStepChange) 114 | 115 | do { 116 | val event = awaitPointerEvent() 117 | event.changes.forEach { pointer -> 118 | if (pointer.positionChanged()) { 119 | pointer.consume() 120 | } 121 | pointer.position 122 | .takeIf { it.isValid(size) } 123 | ?.let { position -> 124 | val stepChange = StepChange(steps, stepSize, margin, position) 125 | if (stepChange.isChecked == firstStepChange?.isChecked) { 126 | onStepChange(stepChange) 127 | } 128 | } 129 | } 130 | } while (event.changes.any { it.pressed }) 131 | } 132 | } 133 | .drawBehind { 134 | drawableSteps.forEach { 135 | drawRoundRect( 136 | color = it.color, 137 | topLeft = it.offset, 138 | size = stepSize, 139 | cornerRadius = cornerRadius, 140 | alpha = 1.0f, 141 | style = Fill 142 | ) 143 | } 144 | } 145 | ) 146 | } 147 | 148 | private fun Offset.isValid(size: IntSize): Boolean = 149 | x.isFinite() && 150 | y.isFinite() && 151 | x >= 0 && 152 | y >= 0 && 153 | x <= size.width && 154 | y <= size.height 155 | 156 | private data class StepChange( 157 | val steps: List, 158 | val stepSize: Size, 159 | val margin: Float, 160 | val position: Offset 161 | ) { 162 | private val step = (position.x / (stepSize.width + margin)).toInt().coerceIn(0, STEPS - 1) 163 | private val channel = (position.y / (stepSize.height + margin)).toInt().coerceIn(0, CHANNELS - 1) 164 | private val mask = MASKS[channel % MASKS.size] 165 | val index = ((channel / MASKS.size) * STEPS) + step 166 | val isChecked = (steps[index] and mask) > 0 167 | val changedStep = steps[index] xor mask 168 | } 169 | 170 | private data class DrawableStep( 171 | val color: Color, 172 | val offset: Offset 173 | ) 174 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/machine/ui/TextButton.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.machine.ui 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.combinedClickable 5 | import androidx.compose.foundation.interaction.MutableInteractionSource 6 | import androidx.compose.foundation.interaction.collectIsPressedAsState 7 | import androidx.compose.foundation.layout.Box 8 | import androidx.compose.material3.MaterialTheme 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.runtime.getValue 11 | import androidx.compose.runtime.remember 12 | import androidx.compose.ui.Alignment 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.semantics.Role 15 | import androidx.lifecycle.compose.dropUnlessResumed 16 | import net.simno.dmach.core.LightMediumText 17 | import net.simno.dmach.core.hapticClick 18 | 19 | @Composable 20 | fun TextButton( 21 | text: String, 22 | selected: Boolean, 23 | onClick: () -> Unit, 24 | modifier: Modifier = Modifier, 25 | enabled: Boolean = true, 26 | muted: Boolean = false, 27 | radioButton: Boolean = false, 28 | onLongClick: (() -> Unit)? = null 29 | ) { 30 | val interactionSource = remember { MutableInteractionSource() } 31 | val pressed by interactionSource.collectIsPressedAsState() 32 | val background = when { 33 | !enabled -> MaterialTheme.colorScheme.surface 34 | selected && radioButton -> MaterialTheme.colorScheme.secondary 35 | pressed -> MaterialTheme.colorScheme.onSecondary 36 | selected -> MaterialTheme.colorScheme.secondary 37 | muted -> MaterialTheme.colorScheme.onSurfaceVariant 38 | else -> MaterialTheme.colorScheme.primary 39 | } 40 | Box( 41 | modifier = modifier 42 | .background( 43 | color = background, 44 | shape = MaterialTheme.shapes.small 45 | ) 46 | .combinedClickable( 47 | onClick = dropUnlessResumed(block = onClick), 48 | onLongClick = hapticClick(block = onLongClick), 49 | enabled = enabled, 50 | role = Role.Button, 51 | interactionSource = interactionSource, 52 | indication = null 53 | ), 54 | contentAlignment = Alignment.Center 55 | ) { 56 | LightMediumText(text = text.uppercase()) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/patch/PatchModule.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.patch 2 | 3 | import dagger.Module 4 | import dagger.Provides 5 | import dagger.hilt.InstallIn 6 | import dagger.hilt.android.components.ViewModelComponent 7 | import dagger.hilt.android.scopes.ViewModelScoped 8 | import net.simno.dmach.db.PatchRepository 9 | import net.simno.dmach.patch.state.PatchProcessor 10 | 11 | @Module 12 | @InstallIn(ViewModelComponent::class) 13 | object PatchModule { 14 | @Provides 15 | @ViewModelScoped 16 | fun providePatchProcesssor(patchRepository: PatchRepository): PatchProcessor = PatchProcessor(patchRepository) 17 | } 18 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/patch/PatchScreen.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.patch 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.LaunchedEffect 5 | import androidx.compose.runtime.getValue 6 | import androidx.compose.runtime.rememberUpdatedState 7 | import androidx.hilt.navigation.compose.hiltViewModel 8 | import androidx.lifecycle.compose.collectAsStateWithLifecycle 9 | import androidx.paging.compose.collectAsLazyPagingItems 10 | import net.simno.dmach.patch.ui.Patch 11 | 12 | @Composable 13 | fun PatchScreen( 14 | navigateUp: () -> Unit, 15 | viewModel: PatchViewModel = hiltViewModel() 16 | ) { 17 | val currentNavigateUp by rememberUpdatedState(navigateUp) 18 | val state by viewModel.viewState.collectAsStateWithLifecycle() 19 | val patches = viewModel.patches.collectAsLazyPagingItems() 20 | 21 | LaunchedEffect(state.finish) { 22 | if (state.finish) { 23 | currentNavigateUp() 24 | } 25 | } 26 | Patch( 27 | state = state, 28 | patches = patches, 29 | onAction = viewModel::onAction 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/patch/PatchViewModel.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.patch 2 | 3 | import androidx.lifecycle.viewModelScope 4 | import androidx.paging.Pager 5 | import androidx.paging.PagingConfig 6 | import androidx.paging.PagingData 7 | import androidx.paging.cachedIn 8 | import androidx.paging.map 9 | import dagger.hilt.android.lifecycle.HiltViewModel 10 | import javax.inject.Inject 11 | import kotlinx.coroutines.flow.Flow 12 | import kotlinx.coroutines.flow.map 13 | import net.simno.dmach.StateViewModel 14 | import net.simno.dmach.data.Patch 15 | import net.simno.dmach.db.PatchRepository 16 | import net.simno.dmach.db.PatchRepository.Companion.toPatch 17 | import net.simno.dmach.patch.state.Action 18 | import net.simno.dmach.patch.state.ErrorResult 19 | import net.simno.dmach.patch.state.LoadAction 20 | import net.simno.dmach.patch.state.PatchProcessor 21 | import net.simno.dmach.patch.state.PatchStateReducer 22 | import net.simno.dmach.patch.state.Result 23 | import net.simno.dmach.patch.state.ViewState 24 | 25 | @HiltViewModel 26 | class PatchViewModel @Inject constructor( 27 | processor: PatchProcessor, 28 | private val repository: PatchRepository 29 | ) : StateViewModel( 30 | processor = processor, 31 | reducer = PatchStateReducer, 32 | onError = { ErrorResult(it) }, 33 | startViewState = ViewState(), 34 | LoadAction 35 | ) { 36 | val patches: Flow> = Pager(PagingConfig(pageSize = 50)) { repository.patches() } 37 | .flow 38 | .map { pagingData -> pagingData.map { entity -> entity.toPatch() } } 39 | .cachedIn(viewModelScope) 40 | } 41 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/patch/state/Action.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.patch.state 2 | 3 | sealed class Action 4 | 5 | data object LoadAction : Action() 6 | 7 | data object DismissAction : Action() 8 | 9 | data object ConfirmOverwriteAction : Action() 10 | 11 | data object ConfirmDeleteAction : Action() 12 | 13 | data class SavePatchAction( 14 | val title: String 15 | ) : Action() 16 | 17 | data class DeletePatchAction( 18 | val title: String 19 | ) : Action() 20 | 21 | data class SelectPatchAction( 22 | val title: String 23 | ) : Action() 24 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/patch/state/PatchProcessor.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.patch.state 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | import kotlinx.coroutines.flow.filterIsInstance 5 | import kotlinx.coroutines.flow.map 6 | import kotlinx.coroutines.flow.merge 7 | import net.simno.dmach.db.PatchRepository 8 | 9 | class PatchProcessor( 10 | private val patchRepository: PatchRepository 11 | ) : (Flow) -> Flow { 12 | 13 | override fun invoke(actions: Flow): Flow = merge( 14 | actions.filterIsInstance().let(load), 15 | actions.filterIsInstance().let(dismiss), 16 | actions.filterIsInstance().let(confirmOverwrite), 17 | actions.filterIsInstance().let(confirmDelete), 18 | actions.filterIsInstance().let(savePatch), 19 | actions.filterIsInstance().let(deletePatch), 20 | actions.filterIsInstance().let(selectPatch) 21 | ) 22 | 23 | private val load: (Flow) -> Flow = { actions -> 24 | actions 25 | .computeResult { 26 | val patch = patchRepository.unsavedPatch() 27 | LoadResult( 28 | title = patch.title 29 | ) 30 | } 31 | } 32 | 33 | private val dismiss: (Flow) -> Flow = { actions -> 34 | actions 35 | .computeResult { 36 | DismissResult 37 | } 38 | } 39 | 40 | private val confirmOverwrite: (Flow) -> Flow = { actions -> 41 | actions 42 | .computeResult { 43 | patchRepository.replacePatch() 44 | ConfirmOverwriteResult 45 | } 46 | } 47 | 48 | private val confirmDelete: (Flow) -> Flow = { actions -> 49 | actions 50 | .computeResult { 51 | patchRepository.deletePatch() 52 | ConfirmDeleteResult 53 | } 54 | } 55 | 56 | private val savePatch: (Flow) -> Flow = { actions -> 57 | actions 58 | .computeResult { action -> 59 | val saved = patchRepository.insertPatch(action.title) 60 | SavePatchResult(!saved, action.title) 61 | } 62 | } 63 | 64 | private val deletePatch: (Flow) -> Flow = { actions -> 65 | actions 66 | .computeResult { action -> 67 | patchRepository.acceptDeleteTitle(action.title) 68 | DeletePatchResult(action.title) 69 | } 70 | } 71 | 72 | private val selectPatch: (Flow) -> Flow = { actions -> 73 | actions 74 | .computeResult { action -> 75 | patchRepository.selectPatch(action.title) 76 | SelectPatchResult 77 | } 78 | } 79 | 80 | private fun Flow.computeResult(mapper: suspend (T) -> R): Flow = map(mapper) 81 | } 82 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/patch/state/PatchStateReducer.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.patch.state 2 | 3 | import net.simno.dmach.util.logError 4 | 5 | object PatchStateReducer : (ViewState, Result) -> ViewState { 6 | override fun invoke(previousState: ViewState, result: Result) = when (result) { 7 | is ErrorResult -> { 8 | logError("PatchStateReducer", "ErrorResult", result.error) 9 | previousState 10 | } 11 | is LoadResult -> previousState.copy( 12 | title = result.title 13 | ) 14 | DismissResult -> previousState.copy( 15 | showDelete = false, 16 | showOverwrite = false 17 | ) 18 | ConfirmOverwriteResult -> previousState.copy( 19 | finish = true, 20 | showOverwrite = false 21 | ) 22 | ConfirmDeleteResult -> previousState.copy( 23 | showDelete = false 24 | ) 25 | is SavePatchResult -> previousState.copy( 26 | finish = !result.showOverwrite, 27 | showOverwrite = result.showOverwrite, 28 | title = result.title 29 | ) 30 | is DeletePatchResult -> previousState.copy( 31 | showDelete = true, 32 | deleteTitle = result.deleteTitle 33 | ) 34 | SelectPatchResult -> previousState.copy( 35 | finish = true 36 | ) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/patch/state/Result.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.patch.state 2 | 3 | sealed class Result 4 | 5 | data class ErrorResult( 6 | val error: Throwable 7 | ) : Result() 8 | 9 | data class LoadResult( 10 | val title: String 11 | ) : Result() 12 | 13 | data object DismissResult : Result() 14 | 15 | data object ConfirmOverwriteResult : Result() 16 | 17 | data object ConfirmDeleteResult : Result() 18 | 19 | data class SavePatchResult( 20 | val showOverwrite: Boolean, 21 | val title: String 22 | ) : Result() 23 | 24 | data class DeletePatchResult( 25 | val deleteTitle: String 26 | ) : Result() 27 | 28 | data object SelectPatchResult : Result() 29 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/patch/state/ViewState.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.patch.state 2 | 3 | data class ViewState( 4 | val finish: Boolean = false, 5 | val showDelete: Boolean = false, 6 | val showOverwrite: Boolean = false, 7 | val deleteTitle: String = "", 8 | val title: String = "" 9 | ) 10 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/playback/AudioFocus.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.playback 2 | 3 | import android.media.AudioAttributes 4 | import android.media.AudioFocusRequest 5 | import android.media.AudioManager 6 | import kotlinx.coroutines.flow.Flow 7 | import kotlinx.coroutines.flow.MutableStateFlow 8 | import kotlinx.coroutines.flow.combine 9 | import kotlinx.coroutines.flow.distinctUntilChanged 10 | import net.simno.dmach.settings.SettingsRepository 11 | 12 | class AudioFocus( 13 | private val audioManager: AudioManager, 14 | settingsRepository: SettingsRepository 15 | ) : AudioManager.OnAudioFocusChangeListener { 16 | 17 | private enum class Focus { 18 | USER_GAIN, 19 | USER_LOSS, 20 | SYSTEM_GAIN, 21 | SYSTEM_LOSS, 22 | NONE 23 | } 24 | 25 | private val audioFocusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) 26 | .setAudioAttributes( 27 | AudioAttributes.Builder() 28 | .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) 29 | .setUsage(AudioAttributes.USAGE_GAME) 30 | .build() 31 | ) 32 | .setWillPauseWhenDucked(true) 33 | .setAcceptsDelayedFocusGain(true) 34 | .setOnAudioFocusChangeListener(this) 35 | .build() 36 | 37 | private var playbackDelayed = false 38 | private var resumeOnFocusGain = false 39 | 40 | private val focusLock = Any() 41 | private val focus = MutableStateFlow(Focus.NONE) 42 | 43 | val audioFocus: Flow = combine( 44 | focus, 45 | settingsRepository.settings 46 | ) { focus, settings -> 47 | if (settings.ignoreAudioFocus) { 48 | when (focus) { 49 | Focus.USER_GAIN, Focus.SYSTEM_GAIN -> true 50 | else -> false 51 | } 52 | } else { 53 | when (focus) { 54 | Focus.USER_GAIN -> { 55 | val result = audioManager.requestAudioFocus(audioFocusRequest) 56 | synchronized(focusLock) { 57 | when (result) { 58 | AudioManager.AUDIOFOCUS_REQUEST_GRANTED -> true 59 | AudioManager.AUDIOFOCUS_REQUEST_DELAYED -> { 60 | playbackDelayed = true 61 | false 62 | } 63 | else -> false 64 | } 65 | } 66 | } 67 | Focus.USER_LOSS -> { 68 | audioManager.abandonAudioFocusRequest(audioFocusRequest) 69 | false 70 | } 71 | Focus.SYSTEM_GAIN -> true 72 | Focus.SYSTEM_LOSS -> false 73 | Focus.NONE -> false 74 | } 75 | } 76 | }.distinctUntilChanged() 77 | 78 | override fun onAudioFocusChange(focusChange: Int) { 79 | when (focusChange) { 80 | AudioManager.AUDIOFOCUS_GAIN -> { 81 | if (playbackDelayed || resumeOnFocusGain) { 82 | synchronized(focusLock) { 83 | playbackDelayed = false 84 | resumeOnFocusGain = false 85 | } 86 | setFocus(Focus.SYSTEM_GAIN) 87 | } 88 | } 89 | AudioManager.AUDIOFOCUS_LOSS -> { 90 | synchronized(focusLock) { 91 | playbackDelayed = false 92 | resumeOnFocusGain = false 93 | } 94 | setFocus(Focus.SYSTEM_LOSS) 95 | } 96 | AudioManager.AUDIOFOCUS_LOSS_TRANSIENT, 97 | AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> { 98 | synchronized(focusLock) { 99 | playbackDelayed = false 100 | resumeOnFocusGain = true 101 | } 102 | setFocus(Focus.SYSTEM_LOSS) 103 | } 104 | } 105 | } 106 | 107 | fun requestAudioFocus() { 108 | setFocus(Focus.USER_GAIN) 109 | } 110 | 111 | fun abandonAudioFocus() { 112 | setFocus(Focus.USER_LOSS) 113 | } 114 | 115 | private fun setFocus(request: Focus) { 116 | focus.tryEmit(request) 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/playback/PlaybackController.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.playback 2 | 3 | import android.content.Context 4 | import androidx.core.content.ContextCompat 5 | import androidx.lifecycle.DefaultLifecycleObserver 6 | import androidx.lifecycle.LifecycleOwner 7 | import java.util.concurrent.atomic.AtomicBoolean 8 | import kotlinx.coroutines.runBlocking 9 | import net.simno.dmach.R 10 | import net.simno.dmach.data.Tempo 11 | import net.simno.kortholt.Kortholt 12 | 13 | class PlaybackController( 14 | private val context: Context, 15 | private val kortholt: Kortholt.Player, 16 | private val pureData: PureData, 17 | private val waveExporter: WaveExporter 18 | ) : DefaultLifecycleObserver { 19 | 20 | private val isPlaying = AtomicBoolean(false) 21 | private var title: String? = null 22 | private var tempo: String? = null 23 | 24 | suspend fun openPatch() { 25 | kortholt.openPatch(R.raw.dmach, "dmach.pd", extractZip = true) 26 | } 27 | 28 | suspend fun startPlayback() { 29 | if (isPlaying.compareAndSet(false, true)) { 30 | startService() 31 | kortholt.startStream() 32 | pureData.startPlayback() 33 | } 34 | } 35 | 36 | fun stopPlayback() { 37 | isPlaying.set(false) 38 | pureData.stopPlayback() 39 | } 40 | 41 | fun updateInfo(title: String, tempo: Tempo) { 42 | this.title = title 43 | this.tempo = "${tempo.value} BPM" 44 | if (isPlaying.get()) { 45 | // Call startService again if we are playing to update the notification. 46 | startService() 47 | } 48 | } 49 | 50 | override fun onPause(owner: LifecycleOwner) { 51 | if (isPlaying.compareAndSet(false, false)) { 52 | stopService() 53 | if (!waveExporter.isExporting()) { 54 | runBlocking { kortholt.stopStream() } 55 | } 56 | } 57 | } 58 | 59 | override fun onDestroy(owner: LifecycleOwner) { 60 | isPlaying.set(false) 61 | stopService() 62 | pureData.stopPlayback() 63 | runBlocking { 64 | kortholt.stopStream() 65 | kortholt.closePatch() 66 | } 67 | } 68 | 69 | private fun startService() { 70 | ContextCompat.startForegroundService(context, PlaybackService.intent(context, title, tempo)) 71 | } 72 | 73 | private fun stopService() { 74 | context.stopService(PlaybackService.intent(context)) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/playback/PlaybackModule.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.playback 2 | 3 | import android.content.Context 4 | import android.media.AudioManager 5 | import androidx.core.content.getSystemService 6 | import dagger.Module 7 | import dagger.Provides 8 | import dagger.hilt.InstallIn 9 | import dagger.hilt.android.qualifiers.ApplicationContext 10 | import dagger.hilt.components.SingletonComponent 11 | import javax.inject.Singleton 12 | import net.simno.dmach.settings.SettingsRepository 13 | import net.simno.kortholt.Kortholt 14 | 15 | @Module 16 | @InstallIn(SingletonComponent::class) 17 | object PlaybackModule { 18 | 19 | @Provides 20 | fun provideAudioManager( 21 | @ApplicationContext context: Context 22 | ): AudioManager = context.getSystemService()!! 23 | 24 | @Provides 25 | @Singleton 26 | fun provideAudioFocus( 27 | audioManager: AudioManager, 28 | settingsRepository: SettingsRepository 29 | ): AudioFocus = AudioFocus(audioManager, settingsRepository) 30 | 31 | @Provides 32 | @Singleton 33 | fun provideKortholtPlayer( 34 | @ApplicationContext context: Context 35 | ): Kortholt.Player = Kortholt.Player.Builder(context) 36 | .build() 37 | .also { Kortholt.setPlayer(it) } 38 | 39 | @Provides 40 | @Singleton 41 | fun provideWaveExporter( 42 | @ApplicationContext context: Context, 43 | kortholt: Kortholt.Player 44 | ): WaveExporter = WaveExporter(context, kortholt) 45 | 46 | @Provides 47 | @Singleton 48 | fun providePureData( 49 | kortholt: Kortholt.Player 50 | ): PureData = PureData(kortholt) 51 | 52 | @Provides 53 | @Singleton 54 | fun providePlaybackController( 55 | @ApplicationContext context: Context, 56 | kortholt: Kortholt.Player, 57 | pureData: PureData, 58 | waveExporter: WaveExporter 59 | ): PlaybackController = PlaybackController(context, kortholt, pureData, waveExporter) 60 | } 61 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/playback/PlaybackService.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.playback 2 | 3 | import android.app.Notification 4 | import android.app.NotificationManager 5 | import android.app.PendingIntent 6 | import android.app.Service 7 | import android.content.Context 8 | import android.content.Intent 9 | import android.media.MediaMetadata 10 | import android.os.IBinder 11 | import android.support.v4.media.MediaMetadataCompat 12 | import android.support.v4.media.session.MediaSessionCompat 13 | import androidx.core.app.NotificationChannelCompat 14 | import androidx.core.app.NotificationCompat 15 | import androidx.core.app.NotificationManagerCompat 16 | import androidx.core.content.ContextCompat 17 | import androidx.core.graphics.drawable.toBitmap 18 | import net.simno.dmach.MainActivity 19 | import net.simno.dmach.R 20 | 21 | class PlaybackService : Service() { 22 | 23 | private val notificationImage 24 | get() = ContextCompat.getDrawable(this, R.drawable.ic_launcher_foreground)?.toBitmap() 25 | 26 | private var mediaSession: MediaSessionCompat? = null 27 | 28 | override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { 29 | createNotificationChannel() 30 | val contentTitle = intent?.getStringExtra(TITLE) ?: getString(R.string.app_name) 31 | val contentText = intent?.getStringExtra(TEMPO).orEmpty() 32 | startForeground(NOTIFICATION_ID, createNotification(contentTitle, contentText)) 33 | return START_STICKY 34 | } 35 | 36 | override fun onBind(intent: Intent?): IBinder? = null 37 | 38 | override fun onDestroy() { 39 | stopForeground(STOP_FOREGROUND_REMOVE) 40 | mediaSession?.release() 41 | super.onDestroy() 42 | } 43 | 44 | private fun createNotificationChannel() { 45 | val channel = NotificationChannelCompat.Builder(CHANNEL_NAME, NotificationManager.IMPORTANCE_LOW) 46 | .setName(CHANNEL_NAME) 47 | .build() 48 | NotificationManagerCompat.from(applicationContext).createNotificationChannel(channel) 49 | } 50 | 51 | private fun createNotification(contentTitle: String, contentText: String): Notification { 52 | val metadata = MediaMetadataCompat.Builder() 53 | .putString(MediaMetadata.METADATA_KEY_TITLE, contentTitle) 54 | .putString(MediaMetadata.METADATA_KEY_ARTIST, contentText) 55 | .putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, notificationImage) 56 | .build() 57 | 58 | mediaSession?.release() 59 | 60 | val session = MediaSessionCompat(this, CHANNEL_NAME) 61 | session.setMetadata(metadata) 62 | 63 | val mediaStyle = androidx.media.app.NotificationCompat.MediaStyle() 64 | .setMediaSession(session.sessionToken) 65 | 66 | mediaSession = session 67 | 68 | val intent = Intent(this, MainActivity::class.java) 69 | val contentIntent = PendingIntent.getActivity(applicationContext, 0, intent, PendingIntent.FLAG_IMMUTABLE) 70 | return NotificationCompat.Builder(this, CHANNEL_NAME) 71 | .setContentTitle(contentTitle) 72 | .setContentText(contentText) 73 | .setContentIntent(contentIntent) 74 | .setSmallIcon(R.drawable.ic_stat_playback) 75 | .setStyle(mediaStyle) 76 | .setOngoing(true) 77 | .setOnlyAlertOnce(true) 78 | .build() 79 | } 80 | 81 | companion object { 82 | private const val TITLE = "TITLE" 83 | private const val TEMPO = "TEMPO" 84 | private const val CHANNEL_NAME = "Playback" 85 | private const val NOTIFICATION_ID = 1337 86 | 87 | fun intent( 88 | context: Context, 89 | title: String? = null, 90 | tempo: String? = null 91 | ): Intent = Intent(context, PlaybackService::class.java).apply { 92 | putExtra(TITLE, title) 93 | putExtra(TEMPO, tempo) 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/playback/PureData.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.playback 2 | 3 | import androidx.annotation.Size 4 | import net.simno.dmach.data.Pan 5 | import net.simno.dmach.data.Patch 6 | import net.simno.dmach.data.Setting 7 | import net.simno.dmach.data.Steps 8 | import net.simno.dmach.data.Swing 9 | import net.simno.dmach.data.Tempo 10 | import net.simno.dmach.util.logSequence 11 | import net.simno.kortholt.Kortholt 12 | 13 | class PureData( 14 | private val kortholt: Kortholt.Player 15 | ) { 16 | 17 | fun startPlayback() { 18 | kortholt.sendBang("play") 19 | } 20 | 21 | fun stopPlayback() { 22 | kortholt.sendBang("stop") 23 | } 24 | 25 | fun changeSequence(@Size(32) sequence: List) { 26 | logSequence("PureData", sequence) 27 | for (step in 0 until Patch.STEPS) { 28 | kortholt.sendList("step", 0, step, sequence[step]) 29 | kortholt.sendList("step", 1, step, sequence[step + Patch.STEPS]) 30 | } 31 | } 32 | 33 | fun changeSetting(channel: String, setting: Setting) { 34 | kortholt.sendList(channel, setting.hIndex, setting.x) 35 | kortholt.sendList(channel, setting.vIndex, setting.y) 36 | } 37 | 38 | fun changePan(channel: String, pan: Pan) { 39 | kortholt.sendFloat(channel + "p", pan.value) 40 | } 41 | 42 | fun changeTempo(tempo: Tempo) { 43 | kortholt.sendFloat("tempo", tempo.value.toFloat()) 44 | } 45 | 46 | fun changeSwing(swing: Swing) { 47 | kortholt.sendFloat("swing", swing.value / 100f) 48 | } 49 | 50 | fun changeSteps(steps: Steps) { 51 | kortholt.sendFloat("steps", steps.value.coerceIn(8, 16).toFloat()) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/playback/WaveExporter.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.playback 2 | 3 | import android.content.Context 4 | import java.io.File 5 | import java.util.concurrent.atomic.AtomicBoolean 6 | import kotlin.time.DurationUnit 7 | import kotlin.time.toDuration 8 | import kotlinx.coroutines.Dispatchers 9 | import kotlinx.coroutines.withContext 10 | import net.simno.dmach.data.Steps 11 | import net.simno.dmach.data.Tempo 12 | import net.simno.kortholt.ExperimentalWaveFile 13 | import net.simno.kortholt.Kortholt 14 | 15 | @OptIn(ExperimentalWaveFile::class) 16 | class WaveExporter( 17 | private val context: Context, 18 | private val kortholt: Kortholt.Player 19 | ) { 20 | private val isExporting = AtomicBoolean(false) 21 | 22 | fun isExporting(): Boolean = isExporting.get() 23 | 24 | suspend fun saveWaveFile( 25 | title: String, 26 | tempo: Tempo, 27 | steps: Steps 28 | ): File? = withContext(Dispatchers.IO) { 29 | isExporting.set(true) 30 | val result = runCatching { 31 | val fileName = "${title}_${tempo.value}_BPM.wav" 32 | val dir = File(context.filesDir, "wav") 33 | dir.mkdirs() 34 | val outputFile = File(dir, fileName) 35 | 36 | val milliSecondsPerBeat = (60 * 1000) / tempo.value.toDouble() 37 | val duration = (steps.value * milliSecondsPerBeat).toDuration(DurationUnit.MILLISECONDS) 38 | 39 | kortholt.saveWaveFile( 40 | outputFile = outputFile, 41 | duration = duration, 42 | startBang = "play", 43 | stopBang = "stop" 44 | ) 45 | outputFile 46 | } 47 | isExporting.set(false) 48 | result.getOrNull() 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/settings/Settings.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.settings 2 | 3 | data class Settings( 4 | val ignoreAudioFocus: Boolean = false, 5 | val sequenceEnabled: Boolean = false, 6 | val soundEnabled: Boolean = false, 7 | val panEnabled: Boolean = false 8 | ) { 9 | val isAnyEnabled: Boolean = sequenceEnabled || soundEnabled || panEnabled 10 | } 11 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/settings/SettingsModule.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.settings 2 | 3 | import android.content.Context 4 | import androidx.datastore.core.DataStore 5 | import androidx.datastore.preferences.core.Preferences 6 | import androidx.datastore.preferences.preferencesDataStore 7 | import dagger.Module 8 | import dagger.Provides 9 | import dagger.hilt.InstallIn 10 | import dagger.hilt.android.qualifiers.ApplicationContext 11 | import dagger.hilt.components.SingletonComponent 12 | import javax.inject.Singleton 13 | 14 | @Module 15 | @InstallIn(SingletonComponent::class) 16 | object SettingsModule { 17 | private val Context.dataStore: DataStore by preferencesDataStore(name = "settings") 18 | 19 | @Provides 20 | @Singleton 21 | fun provideSettingsRepository( 22 | @ApplicationContext context: Context 23 | ): SettingsRepository = SettingsRepository(context.dataStore) 24 | } 25 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/settings/SettingsRepository.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.settings 2 | 3 | import androidx.datastore.core.DataStore 4 | import androidx.datastore.preferences.core.Preferences 5 | import androidx.datastore.preferences.core.booleanPreferencesKey 6 | import androidx.datastore.preferences.core.edit 7 | import kotlinx.coroutines.flow.Flow 8 | import kotlinx.coroutines.flow.map 9 | 10 | class SettingsRepository( 11 | private val preferences: DataStore 12 | ) { 13 | val settings: Flow = preferences.data.map { data -> 14 | Settings( 15 | ignoreAudioFocus = data[IGNORE_AUDIO_FOCUS] == true, 16 | sequenceEnabled = data[SEQUENCE_ENABLED] != false, 17 | soundEnabled = data[SOUND_ENABLED] != false, 18 | panEnabled = data[PAN_ENABLED] != false 19 | ) 20 | } 21 | 22 | suspend fun updateSettings(settings: Settings) { 23 | preferences.edit { prefs -> 24 | prefs[IGNORE_AUDIO_FOCUS] = settings.ignoreAudioFocus 25 | prefs[SEQUENCE_ENABLED] = settings.sequenceEnabled 26 | prefs[SOUND_ENABLED] = settings.soundEnabled 27 | prefs[PAN_ENABLED] = settings.panEnabled 28 | } 29 | } 30 | 31 | companion object { 32 | private val IGNORE_AUDIO_FOCUS = booleanPreferencesKey("IGNORE_AUDIO_FOCUS") 33 | private val SEQUENCE_ENABLED = booleanPreferencesKey("SEQUENCE_ENABLED") 34 | private val SOUND_ENABLED = booleanPreferencesKey("SOUND_ENABLED") 35 | private val PAN_ENABLED = booleanPreferencesKey("PAN_ENABLED") 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | val Surface = Color(0xfff2f2c6) 6 | val OnSurface = Color(0xffc1bf87) 7 | val OnSurfaceVariant = Color(0xff94926f) 8 | 9 | val Primary = Color(0xff302e2c) 10 | val OnPrimary = Color(0xff3e3b39) 11 | 12 | val Secondary = Color(0xffe9950a) 13 | val OnSecondary = Secondary.copy(alpha = 0.5f) 14 | 15 | val Tertiary = Color(0xffb02b2f) 16 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/theme/Dimension.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.theme 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.CompositionLocalProvider 5 | import androidx.compose.runtime.ProvidableCompositionLocal 6 | import androidx.compose.runtime.remember 7 | import androidx.compose.runtime.staticCompositionLocalOf 8 | import androidx.compose.ui.unit.Dp 9 | import androidx.compose.ui.unit.TextUnit 10 | import androidx.compose.ui.unit.dp 11 | import androidx.compose.ui.unit.sp 12 | 13 | sealed class Dimensions( 14 | val circleRadius: Dp, 15 | val rectHeight: Dp, 16 | val configHeightSmall: Dp, 17 | val configHeight: Dp, 18 | val paddingSmall: Dp, 19 | val paddingMedium: Dp, 20 | val paddingLarge: Dp, 21 | val buttonSmall: Dp, 22 | val buttonMedium: Dp, 23 | val buttonLarge: Dp, 24 | val textSmall: TextUnit, 25 | val textMedium: TextUnit, 26 | val textLarge: TextUnit, 27 | val labelSmall: TextUnit, 28 | val labelMedium: TextUnit 29 | ) { 30 | data object Default : Dimensions( 31 | circleRadius = 18.dp, 32 | rectHeight = 44.dp, 33 | configHeightSmall = 48.dp, 34 | configHeight = 64.dp, 35 | paddingSmall = 4.dp, 36 | paddingMedium = 8.dp, 37 | paddingLarge = 12.dp, 38 | buttonSmall = 56.dp, 39 | buttonMedium = 64.dp, 40 | buttonLarge = 72.dp, 41 | textSmall = 16.sp, 42 | textMedium = 18.sp, 43 | textLarge = 24.sp, 44 | labelSmall = 10.sp, 45 | labelMedium = 14.sp 46 | ) 47 | 48 | data object ShortestWidth600 : Dimensions( 49 | circleRadius = 24.dp, 50 | rectHeight = 64.dp, 51 | configHeightSmall = 72.dp, 52 | configHeight = 96.dp, 53 | paddingSmall = 5.dp, 54 | paddingMedium = 10.dp, 55 | paddingLarge = 15.dp, 56 | buttonSmall = 88.dp, 57 | buttonMedium = 96.dp, 58 | buttonLarge = 104.dp, 59 | textSmall = 22.sp, 60 | textMedium = 24.sp, 61 | textLarge = 30.sp, 62 | labelSmall = 12.sp, 63 | labelMedium = 20.sp 64 | ) 65 | } 66 | 67 | @Composable 68 | fun ProvideDimensions( 69 | dimensions: Dimensions, 70 | content: @Composable () -> Unit 71 | ) { 72 | val dimensionSet = remember { dimensions } 73 | CompositionLocalProvider(LocalDimensions provides dimensionSet, content = content) 74 | } 75 | 76 | internal val LocalDimensions: ProvidableCompositionLocal = staticCompositionLocalOf { 77 | Dimensions.Default 78 | } 79 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/theme/Shape.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.theme 2 | 3 | import androidx.compose.foundation.shape.RoundedCornerShape 4 | import androidx.compose.material3.Shapes 5 | import androidx.compose.ui.unit.dp 6 | 7 | val Shapes = Shapes( 8 | small = RoundedCornerShape(size = 2.dp), 9 | medium = RoundedCornerShape(size = 3.dp), 10 | large = RoundedCornerShape(size = 0.dp) 11 | ) 12 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.theme 2 | 3 | import androidx.compose.material3.MaterialTheme 4 | import androidx.compose.material3.lightColorScheme 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.runtime.ReadOnlyComposable 7 | import androidx.compose.ui.platform.LocalConfiguration 8 | 9 | @Composable 10 | fun AppTheme( 11 | content: @Composable () -> Unit 12 | ) { 13 | val configuration = LocalConfiguration.current 14 | val dimensions = if (configuration.smallestScreenWidthDp < 600) { 15 | Dimensions.Default 16 | } else { 17 | Dimensions.ShortestWidth600 18 | } 19 | 20 | ProvideDimensions(dimensions = dimensions) { 21 | MaterialTheme( 22 | colorScheme = lightColorScheme( 23 | background = Surface, 24 | surface = Surface, 25 | onSurface = OnSurface, 26 | onSurfaceVariant = OnSurfaceVariant, 27 | primary = Primary, 28 | onPrimary = OnPrimary, 29 | secondary = Secondary, 30 | onSecondary = OnSecondary, 31 | tertiary = Tertiary 32 | ), 33 | shapes = Shapes, 34 | typography = Typography, 35 | content = content 36 | ) 37 | } 38 | } 39 | 40 | object AppTheme { 41 | val dimens: Dimensions 42 | @ReadOnlyComposable 43 | @Composable 44 | get() = LocalDimensions.current 45 | } 46 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.theme 2 | 3 | import androidx.compose.material3.Typography 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.ui.text.TextStyle 6 | import androidx.compose.ui.text.font.FontFamily 7 | import androidx.compose.ui.text.font.FontWeight 8 | 9 | val Typography: Typography 10 | @Composable 11 | get() = Typography( 12 | bodyLarge = TextStyle( 13 | fontFamily = FontFamily.SansSerif, 14 | fontWeight = FontWeight.Light, 15 | fontSize = AppTheme.dimens.textLarge 16 | ), 17 | bodyMedium = TextStyle( 18 | fontFamily = FontFamily.SansSerif, 19 | fontWeight = FontWeight.Light, 20 | fontSize = AppTheme.dimens.textMedium 21 | ), 22 | bodySmall = TextStyle( 23 | fontFamily = FontFamily.SansSerif, 24 | fontWeight = FontWeight.Light, 25 | fontSize = AppTheme.dimens.textSmall 26 | ), 27 | labelMedium = TextStyle( 28 | fontFamily = FontFamily.SansSerif, 29 | fontWeight = FontWeight.Bold, 30 | fontSize = AppTheme.dimens.labelMedium 31 | ), 32 | labelSmall = TextStyle( 33 | fontFamily = FontFamily.SansSerif, 34 | fontWeight = FontWeight.Light, 35 | fontSize = AppTheme.dimens.labelSmall 36 | ) 37 | ) 38 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/util/DpUtils.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.util 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.remember 5 | import androidx.compose.ui.platform.LocalDensity 6 | import androidx.compose.ui.unit.Dp 7 | 8 | @Composable 9 | fun Dp.toPx(): Float { 10 | val density = LocalDensity.current 11 | return remember(density) { with(density) { toPx() } } 12 | } 13 | -------------------------------------------------------------------------------- /app/src/main/java/net/simno/dmach/util/Logging.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.util 2 | 3 | import android.util.Log 4 | import net.simno.dmach.BuildConfig 5 | import net.simno.dmach.data.Patch.Companion.MASKS 6 | import net.simno.dmach.data.Patch.Companion.STEPS 7 | 8 | fun logSequence(tag: String, sequence: List) { 9 | if (BuildConfig.DEBUG) { 10 | val fold = { index: Int, acc: String, step: Int -> 11 | acc + (if (step > 0) "◼" else "◻") + (if ((index + 1) % 4 == 0) "|" else "") 12 | } 13 | Log.d(tag, " ̲ ̲ ̲ ̲ ̲ ̲ ̲ ̲ ̲ ̲ ̲ ̲ ̲ ̲ ̲ ̲ ̲ ̲ ̲ ̲ ̲ ̲ ̲ ̲") 14 | Log.d(tag, sequence.take(STEPS).map { it and MASKS[0] }.foldIndexed("|BD|", fold)) 15 | Log.d(tag, sequence.take(STEPS).map { it and MASKS[1] }.foldIndexed("|SD|", fold)) 16 | Log.d(tag, sequence.take(STEPS).map { it and MASKS[2] }.foldIndexed("|CP|", fold)) 17 | Log.d(tag, sequence.takeLast(STEPS).map { it and MASKS[0] }.foldIndexed("|TT|", fold)) 18 | Log.d(tag, sequence.takeLast(STEPS).map { it and MASKS[1] }.foldIndexed("|CB|", fold)) 19 | Log.d(tag, sequence.takeLast(STEPS).map { it and MASKS[2] }.foldIndexed("|HH|", fold)) 20 | Log.d(tag, "‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾") 21 | } 22 | } 23 | 24 | fun logError(tag: String, message: String, error: Throwable) { 25 | if (BuildConfig.DEBUG) { 26 | Log.e(tag, message, error) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 13 | 16 | 19 | 20 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_stat_playback.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/raw/dmach.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simonnorberg/dmach/434479ae789ba58b7cbef9e08b6886a9d8c93c20/app/src/main/res/raw/dmach.zip -------------------------------------------------------------------------------- /app/src/main/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #f2f2c6 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | DMach 5 | 6 | BPM 7 | Swing 8 | Steps 9 | More settings 10 | Back 11 | Play over other apps 12 | Reset/Randomize sequencer 13 | Reset/Randomize sound 14 | Reset/Randomize pan 15 | Nothing reset 16 | Nothing randomized 17 | 18 | Delete 19 | Cancel 20 | Overwrite 21 | Delete %1$s? 22 | %1$s already exists 23 | 24 | L 25 | R 26 | 27 | Save patch 28 | Name 29 | 30 | Export wave file (experimental) 31 | Save 32 | Share 33 | 34 | play 35 | configure 36 | reset 37 | randomize 38 | export 39 | 40 | 41 | -------------------------------------------------------------------------------- /app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/xml/file_provider_paths.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | -------------------------------------------------------------------------------- /app/src/test/java/net/simno/dmach/db/TestPatchDao.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.db 2 | 3 | import androidx.paging.PagingSource 4 | import androidx.paging.PagingState 5 | import kotlinx.coroutines.flow.Flow 6 | import kotlinx.coroutines.flow.flow 7 | import net.simno.dmach.data.Steps 8 | import net.simno.dmach.data.Swing 9 | import net.simno.dmach.data.Tempo 10 | import net.simno.dmach.data.defaultPatch 11 | import net.simno.dmach.db.PatchRepository.Companion.toEntity 12 | import net.simno.dmach.machine.state.Randomizer 13 | 14 | class TestPatchDao : PatchDao { 15 | var deleteTitle = "" 16 | 17 | val patch = defaultPatch().copy( 18 | title = "test", 19 | sequence = Randomizer.DEFAULT.nextSequence(), 20 | selectedChannel = 1, 21 | tempo = Tempo(123), 22 | swing = Swing(10), 23 | steps = Steps(16) 24 | ) 25 | 26 | private val patches = listOf(patch, patch) 27 | 28 | private val dataSource = object : PagingSource() { 29 | override suspend fun load(params: LoadParams): LoadResult = 30 | LoadResult.Page(data = patches.map { it.toEntity(it.title) }, null, null) 31 | 32 | override fun getRefreshKey(state: PagingState): Int? = null 33 | } 34 | 35 | override suspend fun count(): Int = patches.size 36 | 37 | override fun getActivePatch(): Flow = flow { emit(patch.toEntity(patch.title)) } 38 | 39 | override fun getAllPatches(): PagingSource = dataSource 40 | 41 | override suspend fun deletePatch(title: String): Int { 42 | deleteTitle = title 43 | return 1 44 | } 45 | 46 | override suspend fun internalResetActive() = Unit 47 | 48 | override suspend fun internalSetActive(title: String): Int = 1 49 | 50 | override suspend fun internalInsertPatch(patch: PatchEntity): Long = 1 51 | 52 | override suspend fun internalReplacePatch(patch: PatchEntity): Long = 1 53 | } 54 | -------------------------------------------------------------------------------- /app/src/test/java/net/simno/dmach/patch/state/PatchProcessorTests.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.patch.state 2 | 3 | import com.google.common.truth.Truth.assertThat 4 | import kotlinx.coroutines.DelicateCoroutinesApi 5 | import kotlinx.coroutines.GlobalScope 6 | import kotlinx.coroutines.channels.Channel.Factory.RENDEZVOUS 7 | import kotlinx.coroutines.delay 8 | import kotlinx.coroutines.flow.SharingStarted 9 | import kotlinx.coroutines.flow.asFlow 10 | import kotlinx.coroutines.flow.buffer 11 | import kotlinx.coroutines.flow.flowOf 12 | import kotlinx.coroutines.flow.onEach 13 | import kotlinx.coroutines.flow.shareIn 14 | import kotlinx.coroutines.flow.take 15 | import kotlinx.coroutines.flow.toList 16 | import kotlinx.coroutines.runBlocking 17 | import net.simno.dmach.db.PatchRepository 18 | import net.simno.dmach.db.TestPatchDao 19 | import net.simno.dmach.machine.state.MachineProcessor 20 | import net.simno.dmach.playback.AudioFocus 21 | import net.simno.dmach.playback.PlaybackController 22 | import net.simno.dmach.playback.PureData 23 | import net.simno.dmach.playback.WaveExporter 24 | import net.simno.dmach.settings.SettingsRepository 25 | import org.junit.Before 26 | import org.junit.Test 27 | import org.mockito.Mockito.mock 28 | 29 | @DelicateCoroutinesApi 30 | class PatchProcessorTests { 31 | 32 | private lateinit var repository: PatchRepository 33 | private lateinit var testDao: TestPatchDao 34 | private lateinit var patchProcessor: PatchProcessor 35 | 36 | private suspend fun processAction(action: Action): Result = processActions(action).first() 37 | 38 | private suspend fun processActions( 39 | vararg actions: Action 40 | ): List = actions.asFlow() 41 | .onEach { delay(10L) } 42 | .buffer(RENDEZVOUS) 43 | .shareIn(GlobalScope, SharingStarted.Lazily) 44 | .let(patchProcessor) 45 | .take(actions.size) 46 | .toList() 47 | 48 | @Before 49 | fun setup() { 50 | testDao = TestPatchDao() 51 | repository = PatchRepository(testDao) 52 | patchProcessor = PatchProcessor(repository) 53 | setupRepository() 54 | } 55 | 56 | private fun setupRepository() = runBlocking { 57 | flowOf(net.simno.dmach.machine.state.LoadAction) 58 | .onEach { delay(10L) } 59 | .buffer(RENDEZVOUS) 60 | .shareIn(GlobalScope, SharingStarted.Lazily) 61 | .let( 62 | MachineProcessor( 63 | playbackController = mock(PlaybackController::class.java), 64 | pureData = mock(PureData::class.java), 65 | waveExporter = mock(WaveExporter::class.java), 66 | audioFocus = mock(AudioFocus::class.java), 67 | patchRepository = repository, 68 | settingsRepository = mock(SettingsRepository::class.java) 69 | ) 70 | ) 71 | .take(1) 72 | .toList() 73 | } 74 | 75 | @Test 76 | fun load() = runBlocking { 77 | val actual = processAction(LoadAction) as LoadResult 78 | assertThat(actual.title).isEqualTo(testDao.patch.title) 79 | } 80 | 81 | @Test 82 | fun dismiss() = runBlocking { 83 | val actual = processAction(DismissAction) 84 | val expected = DismissResult 85 | assertThat(actual).isEqualTo(expected) 86 | } 87 | 88 | @Test 89 | fun confirmOverwrite() = runBlocking { 90 | val actual = processAction(ConfirmOverwriteAction) 91 | val expected = ConfirmOverwriteResult 92 | assertThat(actual).isEqualTo(expected) 93 | } 94 | 95 | @Test 96 | fun confirmDelete() = runBlocking { 97 | val actual = processAction(ConfirmDeleteAction) 98 | val expected = ConfirmDeleteResult 99 | assertThat(actual).isEqualTo(expected) 100 | } 101 | 102 | @Test 103 | fun savePatch() = runBlocking { 104 | val title = "1337" 105 | val actual = processAction(SavePatchAction(title)) 106 | val expected = SavePatchResult(false, title) 107 | assertThat(actual).isEqualTo(expected) 108 | } 109 | 110 | @Test 111 | fun deletePatch() = runBlocking { 112 | val title = "1337" 113 | val actual = processAction(DeletePatchAction(title)) 114 | val expected = DeletePatchResult(title) 115 | processAction(ConfirmDeleteAction) 116 | assertThat(actual).isEqualTo(expected) 117 | assertThat(testDao.deleteTitle).isEqualTo(title) 118 | } 119 | 120 | @Test 121 | fun selectPatch() = runBlocking { 122 | val title = "1337" 123 | val actual = processAction(SelectPatchAction(title)) 124 | val expected = SelectPatchResult 125 | assertThat(actual).isEqualTo(expected) 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /app/src/test/java/net/simno/dmach/playback/AudioFocusTests.kt: -------------------------------------------------------------------------------- 1 | package net.simno.dmach.playback 2 | 3 | import android.app.Application 4 | import android.media.AudioManager 5 | import android.os.Build 6 | import androidx.core.content.getSystemService 7 | import androidx.test.core.app.ApplicationProvider 8 | import androidx.test.ext.junit.runners.AndroidJUnit4 9 | import com.google.common.truth.Truth.assertThat 10 | import kotlinx.coroutines.async 11 | import kotlinx.coroutines.awaitAll 12 | import kotlinx.coroutines.delay 13 | import kotlinx.coroutines.flow.flowOf 14 | import kotlinx.coroutines.flow.take 15 | import kotlinx.coroutines.flow.toList 16 | import kotlinx.coroutines.runBlocking 17 | import net.simno.dmach.settings.Settings 18 | import net.simno.dmach.settings.SettingsRepository 19 | import org.junit.Before 20 | import org.junit.Test 21 | import org.junit.runner.RunWith 22 | import org.mockito.Mock 23 | import org.mockito.MockitoAnnotations 24 | import org.mockito.kotlin.doReturn 25 | import org.mockito.kotlin.whenever 26 | import org.robolectric.Shadows 27 | import org.robolectric.annotation.Config 28 | 29 | @RunWith(AndroidJUnit4::class) 30 | @Config(sdk = [Build.VERSION_CODES.P]) 31 | class AudioFocusTests { 32 | 33 | private val audioManager = ApplicationProvider.getApplicationContext() 34 | .getSystemService()!! 35 | 36 | @Mock 37 | private lateinit var settingsRepository: SettingsRepository 38 | 39 | @Before 40 | fun setup() { 41 | MockitoAnnotations.openMocks(this) 42 | } 43 | 44 | @Test 45 | fun requestAudioFocusGranted() = runBlocking { 46 | val shadowAudioManager = Shadows.shadowOf(audioManager) 47 | shadowAudioManager.setNextFocusRequestResponse(AudioManager.AUDIOFOCUS_REQUEST_GRANTED) 48 | 49 | whenever(settingsRepository.settings) 50 | .doReturn(flowOf(Settings(ignoreAudioFocus = false))) 51 | 52 | val audioFocus = AudioFocus(audioManager, settingsRepository) 53 | 54 | audioFocus.requestAudioFocus() 55 | val expected = listOf(true) 56 | val actual = audioFocus.audioFocus.take(expected.size).toList() 57 | assertThat(actual).isEqualTo(expected) 58 | } 59 | 60 | @Test 61 | fun requestAudioFocusDelayed() = runBlocking { 62 | val shadowAudioManager = Shadows.shadowOf(audioManager) 63 | shadowAudioManager.setNextFocusRequestResponse(AudioManager.AUDIOFOCUS_REQUEST_DELAYED) 64 | 65 | whenever(settingsRepository.settings) 66 | .doReturn(flowOf(Settings(ignoreAudioFocus = false))) 67 | 68 | val audioFocus = AudioFocus(audioManager, settingsRepository) 69 | 70 | audioFocus.requestAudioFocus() 71 | val expected = listOf(false) 72 | val actual = audioFocus.audioFocus.take(expected.size).toList() 73 | assertThat(actual).isEqualTo(expected) 74 | } 75 | 76 | @Test 77 | fun requestAudioFocusFailed() = runBlocking { 78 | val shadowAudioManager = Shadows.shadowOf(audioManager) 79 | shadowAudioManager.setNextFocusRequestResponse(AudioManager.AUDIOFOCUS_REQUEST_FAILED) 80 | 81 | whenever(settingsRepository.settings) 82 | .doReturn(flowOf(Settings(ignoreAudioFocus = false))) 83 | 84 | val audioFocus = AudioFocus(audioManager, settingsRepository) 85 | 86 | audioFocus.requestAudioFocus() 87 | val expected = listOf(false) 88 | val actual = audioFocus.audioFocus.take(expected.size).toList() 89 | assertThat(actual).isEqualTo(expected) 90 | } 91 | 92 | @Suppress("UNCHECKED_CAST") 93 | @Test 94 | fun abandonAudioFocus() = runBlocking { 95 | val shadowAudioManager = Shadows.shadowOf(audioManager) 96 | shadowAudioManager.setNextFocusRequestResponse(AudioManager.AUDIOFOCUS_REQUEST_GRANTED) 97 | 98 | whenever(settingsRepository.settings) 99 | .doReturn(flowOf(Settings(ignoreAudioFocus = false))) 100 | 101 | val audioFocus = AudioFocus(audioManager, settingsRepository) 102 | 103 | val expected = listOf(true, false) 104 | val actual = listOf( 105 | async { 106 | audioFocus.audioFocus.take(expected.size).toList() 107 | }, 108 | async { 109 | audioFocus.requestAudioFocus() 110 | delay(10L) 111 | audioFocus.abandonAudioFocus() 112 | } 113 | ).awaitAll().first() as List 114 | 115 | assertThat(actual).isEqualTo(expected) 116 | } 117 | 118 | @Test 119 | fun ignoreAudioFocus() = runBlocking { 120 | val shadowAudioManager = Shadows.shadowOf(audioManager) 121 | shadowAudioManager.setNextFocusRequestResponse(AudioManager.AUDIOFOCUS_REQUEST_FAILED) 122 | 123 | whenever(settingsRepository.settings) 124 | .doReturn(flowOf(Settings(ignoreAudioFocus = true))) 125 | 126 | val audioFocus = AudioFocus(audioManager, settingsRepository) 127 | 128 | audioFocus.requestAudioFocus() 129 | val expected = listOf(true) 130 | val actual = audioFocus.audioFocus.take(expected.size).toList() 131 | assertThat(actual).isEqualTo(expected) 132 | 133 | assertThat(shadowAudioManager.lastAudioFocusRequest).isNull() 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile 2 | 3 | plugins { 4 | alias(libs.plugins.android.application) apply false 5 | alias(libs.plugins.cachefix) apply false 6 | alias(libs.plugins.hilt.android) apply false 7 | alias(libs.plugins.kotlin.android) apply false 8 | alias(libs.plugins.kotlin.compose) apply false 9 | alias(libs.plugins.kotlin.serialization) apply false 10 | alias(libs.plugins.ksp) apply false 11 | alias(libs.plugins.ktlint.gradle) 12 | alias(libs.plugins.gradle.versions) 13 | } 14 | 15 | allprojects { 16 | plugins.withType().configureEach { 17 | extensions.configure { 18 | toolchain { 19 | languageVersion.set( 20 | JavaLanguageVersion.of(rootProject.libs.versions.javaVersion.get().toInt()) 21 | ) 22 | } 23 | } 24 | } 25 | tasks.withType().configureEach { 26 | compilerOptions { 27 | allWarningsAsErrors = true 28 | freeCompilerArgs.addAll( 29 | "-opt-in=androidx.compose.foundation.ExperimentalFoundationApi", 30 | "-opt-in=androidx.compose.material3.ExperimentalMaterial3Api", 31 | "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi" 32 | ) 33 | } 34 | } 35 | apply(plugin = rootProject.libs.plugins.ktlint.gradle.get().pluginId) 36 | ktlint { 37 | version.set(rootProject.libs.versions.ktlint.asProvider()) 38 | android.set(true) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | 3 | # IDE (e.g. Android Studio) users: 4 | # Gradle settings configured through the IDE *will override* 5 | # any settings specified in this file. 6 | 7 | # For more details on how to configure your build environment visit 8 | # http://www.gradle.org/docs/current/userguide/build_environment.html 9 | 10 | # Specifies the JVM arguments used for the daemon process. 11 | # The setting is particularly useful for tweaking memory settings. 12 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 13 | 14 | # When configured, Gradle will run in incubating parallel mode. 15 | # This option should only be used with decoupled projects. More details, visit 16 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 17 | org.gradle.parallel=true 18 | 19 | org.gradle.configureondemand=true 20 | org.gradle.caching=true 21 | 22 | # AndroidX package structure to make it clearer which packages are bundled with the 23 | # Android operating system, and which are packaged with your app's APK 24 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 25 | android.useAndroidX=true 26 | 27 | # Kotlin code style for this project: "official" or "obsolete": 28 | kotlin.code.style=official 29 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | agp = "8.10.1" 3 | compileSdk = "36" 4 | minSdk = "28" 5 | targetSdk = "36" 6 | ndk = "28.1.13356709" 7 | androidx-activity = "1.10.1" 8 | androidx-core = "1.16.0" 9 | androidx-datastore = "1.1.7" 10 | androidx-hilt = "1.2.0" 11 | androidx-lifecycle = "2.9.1" 12 | androidx-media = "1.7.0" 13 | androidx-navigation = "2.9.0" 14 | androidx-paging = "3.3.6" 15 | androidx-room = "2.7.1" 16 | androidx-test-core = "1.6.1" 17 | androidx-test-espresso = "3.6.1" 18 | androidx-test-junit = "1.2.1" 19 | androidx-test-runner = "1.6.2" 20 | androidx-test-truth = "1.6.0" 21 | cachefix = "3.0.1" 22 | compose-bom = "2025.06.00" 23 | compose-lint = "1.4.2" 24 | coroutines = "1.10.2" 25 | dmach-externals = "3.4.0" 26 | dagger = "2.56.2" 27 | gradle-versions = "0.52.0" 28 | javaVersion = "21" 29 | kortholt = "3.4.0" 30 | kotlin = "2.1.21" 31 | kotlin-collections = "0.4.0" 32 | kotlin-serialization = "1.8.1" 33 | ktlint = "1.6.0" 34 | ktlint-compose = "0.4.22" 35 | ktlint-gradle = "12.2.0" 36 | ksp = "2.1.21-2.0.1" 37 | leakcanary = "2.14" 38 | mockito-core = "5.18.0" 39 | mockito-kotlin = "5.4.0" 40 | robolectric = "4.14.1" 41 | 42 | [plugins] 43 | android-application = { id = "com.android.application", version.ref = "agp" } 44 | hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "dagger" } 45 | kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } 46 | kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } 47 | kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } 48 | ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } 49 | ktlint-gradle = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint-gradle" } 50 | cachefix = { id = "org.gradle.android.cache-fix", version.ref = "cachefix" } 51 | gradle-versions = { id = "com.github.ben-manes.versions", version.ref = "gradle-versions" } 52 | 53 | [libraries] 54 | androidx-activity = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } 55 | androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } 56 | androidx-datastore = { module = "androidx.datastore:datastore-preferences", version.ref = "androidx-datastore" } 57 | androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "androidx-hilt" } 58 | androidx-hilt-navigation = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "androidx-hilt" } 59 | androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } 60 | androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidx-lifecycle" } 61 | androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } 62 | androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle" } 63 | androidx-media = { module = "androidx.media:media", version.ref = "androidx-media" } 64 | androidx-navigation = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation" } 65 | androidx-paging-common = { module = "androidx.paging:paging-common-ktx", version.ref = "androidx-paging" } 66 | androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "androidx-paging" } 67 | androidx-paging-runtime = { module = "androidx.paging:paging-runtime-ktx", version.ref = "androidx-paging" } 68 | androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "androidx-room" } 69 | androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "androidx-room" } 70 | androidx-room-paging = { module = "androidx.room:room-paging", version.ref = "androidx-room" } 71 | androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "androidx-room" } 72 | androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "androidx-room" } 73 | androidx-test-core = { module = "androidx.test:core", version.ref = "androidx-test-core" } 74 | androidx-test-espresso = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-test-espresso" } 75 | androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-junit" } 76 | androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" } 77 | androidx-test-truth = { module = "androidx.test.ext:truth", version.ref = "androidx-test-truth" } 78 | compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" } 79 | compose-lint = { module = "com.slack.lint.compose:compose-lint-checks", version.ref = "compose-lint" } 80 | compose-material = { module = "androidx.compose.material3:material3" } 81 | compose-material-icons = { module = "androidx.compose.material:material-icons-extended" } 82 | compose-ui = { module = "androidx.compose.ui:ui" } 83 | compose-ui-testmanifest = { module = "androidx.compose.ui:ui-test-manifest" } 84 | compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } 85 | compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } 86 | coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android" } 87 | coroutines-bom = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-bom", version.ref = "coroutines" } 88 | coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core" } 89 | dmach-externals = { module = "com.github.simonnorberg:dmach-externals", version.ref = "dmach-externals" } 90 | hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "dagger" } 91 | hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "dagger" } 92 | kortholt = { module = "net.simno.kortholt:kortholt", version.ref = "kortholt" } 93 | kotlin-collections = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlin-collections" } 94 | kotlin-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlin-serialization" } 95 | ktlint-compose = { module = "io.nlopez.compose.rules:ktlint", version.ref = "ktlint-compose" } 96 | leakcanary-plumber = { module = "com.squareup.leakcanary:plumber-android", version.ref = "leakcanary" } 97 | mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockito-core" } 98 | mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockito-kotlin" } 99 | robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } 100 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simonnorberg/dmach/434479ae789ba58b7cbef9e08b6886a9d8c93c20/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.2-all.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /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 | # SPDX-License-Identifier: Apache-2.0 19 | # 20 | 21 | ############################################################################## 22 | # 23 | # Gradle start up script for POSIX generated by Gradle. 24 | # 25 | # Important for running: 26 | # 27 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 28 | # noncompliant, but you have some other compliant shell such as ksh or 29 | # bash, then to run this script, type that shell name before the whole 30 | # command line, like: 31 | # 32 | # ksh Gradle 33 | # 34 | # Busybox and similar reduced shells will NOT work, because this script 35 | # requires all of these POSIX shell features: 36 | # * functions; 37 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 38 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 39 | # * compound commands having a testable exit status, especially «case»; 40 | # * various built-in commands including «command», «set», and «ulimit». 41 | # 42 | # Important for patching: 43 | # 44 | # (2) This script targets any POSIX shell, so it avoids extensions provided 45 | # by Bash, Ksh, etc; in particular arrays are avoided. 46 | # 47 | # The "traditional" practice of packing multiple parameters into a 48 | # space-separated string is a well documented source of bugs and security 49 | # problems, so this is (mostly) avoided, by progressively accumulating 50 | # options in "$@", and eventually passing that to Java. 51 | # 52 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 53 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 54 | # see the in-line comments for details. 55 | # 56 | # There are tweaks for specific operating systems such as AIX, CygWin, 57 | # Darwin, MinGW, and NonStop. 58 | # 59 | # (3) This script is generated from the Groovy template 60 | # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 61 | # within the Gradle project. 62 | # 63 | # You can find Gradle at https://github.com/gradle/gradle/. 64 | # 65 | ############################################################################## 66 | 67 | # Attempt to set APP_HOME 68 | 69 | # Resolve links: $0 may be a link 70 | app_path=$0 71 | 72 | # Need this for daisy-chained symlinks. 73 | while 74 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 75 | [ -h "$app_path" ] 76 | do 77 | ls=$( ls -ld "$app_path" ) 78 | link=${ls#*' -> '} 79 | case $link in #( 80 | /*) app_path=$link ;; #( 81 | *) app_path=$APP_HOME$link ;; 82 | esac 83 | done 84 | 85 | # This is normally unused 86 | # shellcheck disable=SC2034 87 | APP_BASE_NAME=${0##*/} 88 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 89 | APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit 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="\\\"\\\"" 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 | if ! command -v java >/dev/null 2>&1 137 | then 138 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 139 | 140 | Please set the JAVA_HOME variable in your environment to match the 141 | location of your Java installation." 142 | fi 143 | fi 144 | 145 | # Increase the maximum file descriptors if we can. 146 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 147 | case $MAX_FD in #( 148 | max*) 149 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 150 | # shellcheck disable=SC2039,SC3045 151 | MAX_FD=$( ulimit -H -n ) || 152 | warn "Could not query maximum file descriptor limit" 153 | esac 154 | case $MAX_FD in #( 155 | '' | soft) :;; #( 156 | *) 157 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 158 | # shellcheck disable=SC2039,SC3045 159 | ulimit -n "$MAX_FD" || 160 | warn "Could not set maximum file descriptor limit to $MAX_FD" 161 | esac 162 | fi 163 | 164 | # Collect all arguments for the java command, stacking in reverse order: 165 | # * args from the command line 166 | # * the main class name 167 | # * -classpath 168 | # * -D...appname settings 169 | # * --module-path (only if needed) 170 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 171 | 172 | # For Cygwin or MSYS, switch paths to Windows format before running java 173 | if "$cygwin" || "$msys" ; then 174 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 175 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 176 | 177 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 178 | 179 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 180 | for arg do 181 | if 182 | case $arg in #( 183 | -*) false ;; # don't mess with options #( 184 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 185 | [ -e "$t" ] ;; #( 186 | *) false ;; 187 | esac 188 | then 189 | arg=$( cygpath --path --ignore --mixed "$arg" ) 190 | fi 191 | # Roll the args list around exactly as many times as the number of 192 | # args, so each arg winds up back in the position where it started, but 193 | # possibly modified. 194 | # 195 | # NB: a `for` loop captures its iteration list before it begins, so 196 | # changing the positional parameters here affects neither the number of 197 | # iterations, nor the values presented in `arg`. 198 | shift # remove old arg 199 | set -- "$@" "$arg" # push replacement arg 200 | done 201 | fi 202 | 203 | 204 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 205 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 206 | 207 | # Collect all arguments for the java command: 208 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 209 | # and any embedded shellness will be escaped. 210 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 211 | # treated as '${Hostname}' itself on the command line. 212 | 213 | set -- \ 214 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 215 | -classpath "$CLASSPATH" \ 216 | -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ 217 | "$@" 218 | 219 | # Stop when "xargs" is not available. 220 | if ! command -v xargs >/dev/null 2>&1 221 | then 222 | die "xargs is not available" 223 | fi 224 | 225 | # Use "xargs" to parse quoted args. 226 | # 227 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 228 | # 229 | # In Bash we could simply go: 230 | # 231 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 232 | # set -- "${ARGS[@]}" "$@" 233 | # 234 | # but POSIX shell has neither arrays nor command substitution, so instead we 235 | # post-process each arg (as a line of input to sed) to backslash-escape any 236 | # character that might be a shell metacharacter, then use eval to reverse 237 | # that process (while maintaining the separation between arguments), and wrap 238 | # the whole thing up as a single "set" statement. 239 | # 240 | # This will of course break if any of these variables contains a newline or 241 | # an unmatched quote. 242 | # 243 | 244 | eval "set -- $( 245 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 246 | xargs -n1 | 247 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 248 | tr '\n' ' ' 249 | )" '"$@"' 250 | 251 | exec "$JAVACMD" "$@" 252 | -------------------------------------------------------------------------------- /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 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH= 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /lint.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | -------------------------------------------------------------------------------- /screenshots/dmach-1.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simonnorberg/dmach/434479ae789ba58b7cbef9e08b6886a9d8c93c20/screenshots/dmach-1.webp -------------------------------------------------------------------------------- /screenshots/dmach-2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simonnorberg/dmach/434479ae789ba58b7cbef9e08b6886a9d8c93c20/screenshots/dmach-2.webp -------------------------------------------------------------------------------- /screenshots/dmach-3.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simonnorberg/dmach/434479ae789ba58b7cbef9e08b6886a9d8c93c20/screenshots/dmach-3.webp -------------------------------------------------------------------------------- /screenshots/dmach-4.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simonnorberg/dmach/434479ae789ba58b7cbef9e08b6886a9d8c93c20/screenshots/dmach-4.webp -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | google() 4 | mavenCentral() 5 | gradlePluginPortal() 6 | } 7 | } 8 | 9 | dependencyResolutionManagement { 10 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 11 | repositories { 12 | google() 13 | mavenCentral() 14 | maven { 15 | url = uri("https://jitpack.io") 16 | content { 17 | includeModule("com.github.simonnorberg", "dmach-externals") 18 | } 19 | } 20 | } 21 | } 22 | 23 | rootProject.name = "dmach" 24 | include(":app") 25 | --------------------------------------------------------------------------------