commit d09cebe4d01159eb5288734e1c7fc31065b1940b Author: Jean Schurger <jean@schurger.org> Date: Wed, 1 Apr 2026 08:42:45 -0400 Initial commit: Sonodrill audio practice app Kotlin Multiplatform + Compose Multiplatform app for music practice with mark-based looping, speed control, and multi-row progress timeline. Targets iOS (AVAudioEngine) and Android (ExoPlayer). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Diffstat:
55 files changed, 2445 insertions(+), 0 deletions(-)
diff --git a/.gitignore b/.gitignore @@ -0,0 +1,10 @@ +build/ +.gradle/ +.idea/ +*.iml +local.properties +.DS_Store +iosApp/*.xcworkspace +iosApp/*.xcodeproj/xcuserdata +iosApp/Pods/ +*.xcuserstate diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts @@ -0,0 +1,30 @@ +plugins { + alias(libs.plugins.androidApplication) + alias(libs.plugins.composeCompiler) + kotlin("android") +} + +android { + namespace = "ubi2.sonodrill.android" + compileSdk = 35 + defaultConfig { + applicationId = "ubi2.sonodrill" + minSdk = 26 + targetSdk = 35 + versionCode = 1 + versionName = "1.0.0" + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + buildFeatures { compose = true } +} + +dependencies { + implementation(project(":composeApp")) + implementation(platform("androidx.compose:compose-bom:2024.12.01")) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.material3:material3") + implementation("androidx.activity:activity-compose:1.9.3") +} diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest xmlns:android="http://schemas.android.com/apk/res/android"> + <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> + <uses-permission android:name="android.permission.READ_MEDIA_AUDIO" /> + + <application + android:allowBackup="true" + android:label="Sonodrill" + android:supportsRtl="true" + android:theme="@style/Theme.Sonodrill"> + <activity + android:name="ubi2.sonodrill.MainActivity" + android:exported="true" + android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"> + <intent-filter> + <action android:name="android.intent.action.MAIN" /> + <category android:name="android.intent.category.LAUNCHER" /> + </intent-filter> + </activity> + </application> +</manifest> diff --git a/androidApp/src/main/kotlin/ubi2/sonodrill/MainActivity.kt b/androidApp/src/main/kotlin/ubi2/sonodrill/MainActivity.kt @@ -0,0 +1,44 @@ +package ubi2.sonodrill + +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import ubi2.sonodrill.audio.AndroidAudioPlayer +import ubi2.sonodrill.audio.app_context +import ubi2.sonodrill.data.AudioRepository +import ubi2.sonodrill.data.DriverFactory +import ubi2.sonodrill.data.create_database +import ubi2.sonodrill.model.AudioFileInfo +import ubi2.sonodrill.model.current_time_ms +import ubi2.sonodrill.ui.SonodrillApp + +class MainActivity : ComponentActivity() { + private lateinit var player: AndroidAudioPlayer + private lateinit var repo: AudioRepository + + private val file_picker = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + uri?.let { + contentResolver.takePersistableUriPermission(it, Intent.FLAG_GRANT_READ_URI_PERMISSION) + val title = uri.lastPathSegment?.substringAfterLast('/') ?: "Unknown" + repo.upsert_file(AudioFileInfo(id = uri.toString().hashCode().toString(), uri = uri.toString(), title = title, updated_at = current_time_ms())) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + app_context = applicationContext + player = AndroidAudioPlayer(applicationContext) + repo = AudioRepository(create_database(DriverFactory(applicationContext))) + + setContent { + SonodrillApp( + player = player, repo = repo, + on_import = { file_picker.launch(arrayOf("audio/mpeg", "audio/wav", "audio/aac", "audio/mp4", "audio/flac", "audio/ogg", "audio/*")) }, + ) + } + } + + override fun onDestroy() { super.onDestroy(); player.release() } +} diff --git a/androidApp/src/main/res/values/themes.xml b/androidApp/src/main/res/values/themes.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <style name="Theme.Sonodrill" parent="android:Theme.Material.Light.NoActionBar" /> +</resources> diff --git a/build.gradle.kts b/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + alias(libs.plugins.androidApplication) apply false + alias(libs.plugins.androidLibrary) apply false + alias(libs.plugins.kotlinMultiplatform) apply false + alias(libs.plugins.composeMultiplatform) apply false + alias(libs.plugins.composeCompiler) apply false + alias(libs.plugins.sqldelight) apply false +} diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts @@ -0,0 +1,66 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidLibrary) + alias(libs.plugins.composeMultiplatform) + alias(libs.plugins.composeCompiler) + alias(libs.plugins.sqldelight) +} + +kotlin { + androidTarget { + compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } + } + + listOf(iosX64(), iosArm64(), iosSimulatorArm64()).forEach { + it.binaries.framework { + baseName = "ComposeApp" + isStatic = true + } + } + + sourceSets { + commonMain.dependencies { + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material3) + implementation(compose.ui) + implementation(compose.components.resources) + implementation(compose.materialIconsExtended) + + implementation(libs.kotlinx.coroutines.core) + implementation(libs.sqldelight.runtime) + implementation(libs.sqldelight.coroutines) + implementation(libs.koin.core) + implementation(libs.koin.compose) + implementation(libs.navigation.compose) + } + androidMain.dependencies { + implementation(libs.kotlinx.coroutines.android) + implementation(libs.sqldelight.android) + implementation(libs.exoplayer) + } + iosMain.dependencies { + implementation(libs.sqldelight.native) + } + } +} + +android { + namespace = "ubi2.sonodrill" + compileSdk = 35 + defaultConfig { minSdk = 26 } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +sqldelight { + databases { + create("SonodrillDatabase") { + packageName.set("ubi2.sonodrill.data") + } + } +} diff --git a/composeApp/src/androidMain/kotlin/ubi2/sonodrill/audio/android_audio_player.kt b/composeApp/src/androidMain/kotlin/ubi2/sonodrill/audio/android_audio_player.kt @@ -0,0 +1,54 @@ +package ubi2.sonodrill.audio + +import android.content.Context +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackParameters +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class AndroidAudioPlayer(private val context: Context) : AudioPlayer { + private var exo: ExoPlayer? = null + private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + + private val _position = MutableStateFlow(0L) + override val current_position_ms: StateFlow<Long> = _position.asStateFlow() + private val _duration = MutableStateFlow(0L) + override val duration_ms: StateFlow<Long> = _duration.asStateFlow() + private val _playing = MutableStateFlow(false) + override val is_playing: StateFlow<Boolean> = _playing.asStateFlow() + private val _speed = MutableStateFlow(1f) + override val playback_speed: StateFlow<Float> = _speed.asStateFlow() + + private var position_job: Job? = null + + override fun load(uri: String) { + release_player() + exo = ExoPlayer.Builder(context).build().apply { + setMediaItem(MediaItem.fromUri(uri)); prepare() + addListener(object : Player.Listener { + override fun onPlaybackStateChanged(state: Int) { if (state == Player.STATE_READY) _duration.value = duration } + override fun onIsPlayingChanged(playing: Boolean) { _playing.value = playing; if (playing) start_position_updates() else stop_position_updates() } + }) + } + } + + override fun play() { exo?.play() } + override fun pause() { exo?.pause() } + override fun toggle() { if (_playing.value) pause() else play() } + override fun seek(position_ms: Long) { exo?.seekTo(position_ms); _position.value = position_ms } + override fun set_speed(rate: Float) { _speed.value = rate; exo?.playbackParameters = PlaybackParameters(rate) } + override fun skip(delta_ms: Long) = seek((current_position_ms.value + delta_ms).coerceIn(0, duration_ms.value)) + override fun release() { release_player(); scope.cancel() } + + private fun release_player() { stop_position_updates(); exo?.release(); exo = null } + private fun start_position_updates() { position_job?.cancel(); position_job = scope.launch { while (isActive) { _position.value = exo?.currentPosition ?: 0; delay(100) } } } + private fun stop_position_updates() { position_job?.cancel(); position_job = null } +} + +lateinit var app_context: Context +actual fun create_audio_player(): AudioPlayer = AndroidAudioPlayer(app_context) +actual fun extract_waveform(uri: String, num_samples: Int): FloatArray = FloatArray(0) diff --git a/composeApp/src/androidMain/kotlin/ubi2/sonodrill/data/android_database.kt b/composeApp/src/androidMain/kotlin/ubi2/sonodrill/data/android_database.kt @@ -0,0 +1,9 @@ +package ubi2.sonodrill.data + +import android.content.Context +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.driver.android.AndroidSqliteDriver + +actual class DriverFactory(private val context: Context) { + actual fun create(): SqlDriver = AndroidSqliteDriver(SonodrillDatabase.Schema, context, "sonodrill.db") +} diff --git a/composeApp/src/androidMain/kotlin/ubi2/sonodrill/model/android_platform.kt b/composeApp/src/androidMain/kotlin/ubi2/sonodrill/model/android_platform.kt @@ -0,0 +1,5 @@ +package ubi2.sonodrill.model + +actual fun current_time_ms(): Long = System.currentTimeMillis() +actual fun generate_uuid(): String = java.util.UUID.randomUUID().toString() +actual fun file_exists(path: String): Boolean = java.io.File(path).exists() diff --git a/composeApp/src/androidMain/kotlin/ubi2/sonodrill/sync/android_sync.kt b/composeApp/src/androidMain/kotlin/ubi2/sonodrill/sync/android_sync.kt @@ -0,0 +1,14 @@ +package ubi2.sonodrill.sync + +import ubi2.sonodrill.model.AudioFileInfo +import ubi2.sonodrill.model.MarkInfo + +class AndroidSyncService : SyncService { + override suspend fun push_files(files: List<AudioFileInfo>) {} + override suspend fun push_marks(marks: List<MarkInfo>) {} + override suspend fun pull_files(): List<AudioFileInfo> = emptyList() + override suspend fun pull_marks(): List<MarkInfo> = emptyList() + override suspend fun sync() {} +} + +actual fun create_sync_service(): SyncService = AndroidSyncService() diff --git a/composeApp/src/commonMain/kotlin/ubi2/sonodrill/audio/audio_player.kt b/composeApp/src/commonMain/kotlin/ubi2/sonodrill/audio/audio_player.kt @@ -0,0 +1,22 @@ +package ubi2.sonodrill.audio + +import kotlinx.coroutines.flow.StateFlow + +interface AudioPlayer { + val current_position_ms: StateFlow<Long> + val duration_ms: StateFlow<Long> + val is_playing: StateFlow<Boolean> + val playback_speed: StateFlow<Float> + + fun load(uri: String) + fun play() + fun pause() + fun toggle() + fun seek(position_ms: Long) + fun set_speed(rate: Float) + fun skip(delta_ms: Long) + fun release() +} + +expect fun create_audio_player(): AudioPlayer +expect fun extract_waveform(uri: String, num_samples: Int): FloatArray diff --git a/composeApp/src/commonMain/kotlin/ubi2/sonodrill/audio/loop_controller.kt b/composeApp/src/commonMain/kotlin/ubi2/sonodrill/audio/loop_controller.kt @@ -0,0 +1,58 @@ +package ubi2.sonodrill.audio + +import ubi2.sonodrill.model.LoopState +import ubi2.sonodrill.model.MarkInfo +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class LoopController(private val player: AudioPlayer) { + private val _state = MutableStateFlow(LoopState()) + val state: StateFlow<LoopState> = _state.asStateFlow() + + private var loop_job: Job? = null + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + + fun set_loop(a: MarkInfo, b: MarkInfo) { + val (start, end) = if (a.position_ms <= b.position_ms) a to b else b to a + _state.value = LoopState(mark_a = start, mark_b = end, enabled = true) + player.seek(start.position_ms) + start_monitoring() + } + + fun toggle() { + val current = _state.value + if (current.mark_a == null || current.mark_b == null) return + _state.value = current.copy(enabled = !current.enabled) + if (_state.value.enabled) start_monitoring() else stop_monitoring() + } + + fun clear() { + stop_monitoring() + _state.value = LoopState() + } + + private fun start_monitoring() { + loop_job?.cancel() + loop_job = scope.launch { + while (isActive && _state.value.enabled) { + val pos = player.current_position_ms.value + val end = _state.value.mark_b?.position_ms ?: break + val start = _state.value.mark_a?.position_ms ?: break + if (pos >= end) player.seek(start) + delay(50) + } + } + } + + private fun stop_monitoring() { + loop_job?.cancel() + loop_job = null + } + + fun release() { + stop_monitoring() + scope.cancel() + } +} diff --git a/composeApp/src/commonMain/kotlin/ubi2/sonodrill/data/database.kt b/composeApp/src/commonMain/kotlin/ubi2/sonodrill/data/database.kt @@ -0,0 +1,10 @@ +package ubi2.sonodrill.data + +import app.cash.sqldelight.db.SqlDriver + +expect class DriverFactory { + fun create(): SqlDriver +} + +fun create_database(driver_factory: DriverFactory): SonodrillDatabase = + SonodrillDatabase(driver_factory.create()) diff --git a/composeApp/src/commonMain/kotlin/ubi2/sonodrill/data/repository.kt b/composeApp/src/commonMain/kotlin/ubi2/sonodrill/data/repository.kt @@ -0,0 +1,65 @@ +package ubi2.sonodrill.data + +import ubi2.sonodrill.model.AudioFileInfo +import ubi2.sonodrill.model.MarkInfo +import ubi2.sonodrill.model.current_time_ms +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map + +class AudioRepository(private val db: SonodrillDatabase) { + private val queries get() = db.audioFileQueries + private val mark_queries get() = db.markQueries + + fun get_all_files(): Flow<List<AudioFileInfo>> { + val trigger = MutableStateFlow(0L) + return trigger.map { + queries.select_all().executeAsList().map { it.to_info() } + } + } + + fun get_all_files_sync(): List<AudioFileInfo> = + queries.select_all().executeAsList().map { it.to_info() } + + fun get_file(id: String): AudioFileInfo? = + queries.select_by_id(id).executeAsOneOrNull()?.to_info() + + fun upsert_file(file: AudioFileInfo) = queries.upsert( + id = file.id, + uri = file.uri, + title = file.title, + duration_ms = file.duration_ms, + last_position_ms = file.last_position_ms, + last_speed = file.last_speed.toDouble(), + updated_at = file.updated_at, + ) + + fun update_position(id: String, position_ms: Long, speed: Float) = + queries.update_position(position_ms, speed.toDouble(), current_time_ms(), id) + + fun delete_file(id: String) = queries.delete_by_id(id) + + fun get_marks(file_id: String): List<MarkInfo> = + mark_queries.select_by_file(file_id).executeAsList().map { it.to_mark_info() } + + fun upsert_mark(mark: MarkInfo) = mark_queries.upsert( + id = mark.id, + file_id = mark.file_id, + position_ms = mark.position_ms, + label = mark.label, + color = mark.color, + created_at = mark.created_at, + ) + + fun delete_mark(id: String) = mark_queries.delete_by_id(id) +} + +private fun Audio_file.to_info() = AudioFileInfo( + id = id, uri = uri, title = title, duration_ms = duration_ms, + last_position_ms = last_position_ms, last_speed = last_speed.toFloat(), updated_at = updated_at, +) + +private fun Mark.to_mark_info() = MarkInfo( + id = id, file_id = file_id, position_ms = position_ms, + label = label, color = color, created_at = created_at, +) diff --git a/composeApp/src/commonMain/kotlin/ubi2/sonodrill/model/models.kt b/composeApp/src/commonMain/kotlin/ubi2/sonodrill/model/models.kt @@ -0,0 +1,26 @@ +package ubi2.sonodrill.model + +data class AudioFileInfo( + val id: String, + val uri: String, + val title: String, + val duration_ms: Long? = null, + val last_position_ms: Long = 0, + val last_speed: Float = 1f, + val updated_at: Long = 0, +) + +data class MarkInfo( + val id: String, + val file_id: String, + val position_ms: Long, + val label: String? = null, + val color: String = "#FF6B35", + val created_at: Long = 0, +) + +data class LoopState( + val mark_a: MarkInfo? = null, + val mark_b: MarkInfo? = null, + val enabled: Boolean = false, +) diff --git a/composeApp/src/commonMain/kotlin/ubi2/sonodrill/model/platform.kt b/composeApp/src/commonMain/kotlin/ubi2/sonodrill/model/platform.kt @@ -0,0 +1,5 @@ +package ubi2.sonodrill.model + +expect fun current_time_ms(): Long +expect fun generate_uuid(): String +expect fun file_exists(path: String): Boolean diff --git a/composeApp/src/commonMain/kotlin/ubi2/sonodrill/sync/sync_service.kt b/composeApp/src/commonMain/kotlin/ubi2/sonodrill/sync/sync_service.kt @@ -0,0 +1,14 @@ +package ubi2.sonodrill.sync + +import ubi2.sonodrill.model.AudioFileInfo +import ubi2.sonodrill.model.MarkInfo + +interface SyncService { + suspend fun push_files(files: List<AudioFileInfo>) + suspend fun push_marks(marks: List<MarkInfo>) + suspend fun pull_files(): List<AudioFileInfo> + suspend fun pull_marks(): List<MarkInfo> + suspend fun sync() +} + +expect fun create_sync_service(): SyncService diff --git a/composeApp/src/commonMain/kotlin/ubi2/sonodrill/ui/app.kt b/composeApp/src/commonMain/kotlin/ubi2/sonodrill/ui/app.kt @@ -0,0 +1,58 @@ +package ubi2.sonodrill.ui + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.* +import androidx.compose.ui.graphics.Color +import ubi2.sonodrill.audio.AudioPlayer +import ubi2.sonodrill.data.AudioRepository +import ubi2.sonodrill.model.AudioFileInfo +import ubi2.sonodrill.model.file_exists +import ubi2.sonodrill.ui.library.LibraryScreen +import ubi2.sonodrill.ui.player.PlayerScreen +import ubi2.sonodrill.ui.player.PlayerViewModel + +private val light_scheme = lightColorScheme( + primary = Color(0xFF1A73E8), primaryContainer = Color(0xFFD2E3FC), secondary = Color(0xFFFF6B35), +) +private val dark_scheme = darkColorScheme( + primary = Color(0xFF8AB4F8), primaryContainer = Color(0xFF004A77), secondary = Color(0xFFFF8A65), +) + +enum class Screen { LIBRARY, PLAYER } + +@Composable +fun SonodrillApp(player: AudioPlayer, repo: AudioRepository, on_import: (on_done: () -> Unit) -> Unit, is_dark: Boolean = false) { + val scheme = if (is_dark) dark_scheme else light_scheme + var screen by remember { mutableStateOf(Screen.LIBRARY) } + var player_vm by remember { mutableStateOf<PlayerViewModel?>(null) } + var files by remember { mutableStateOf(emptyList<AudioFileInfo>()) } + var initialized by remember { mutableStateOf(false) } + + fun refresh_files() { + val all = repo.get_all_files_sync() + // Remove files that no longer exist on disk + val (existing, missing) = all.partition { file_exists(it.uri) } + missing.forEach { repo.delete_file(it.id) } + files = existing + } + + if (!initialized) { refresh_files(); initialized = true } + + MaterialTheme(colorScheme = scheme) { + when (screen) { + Screen.LIBRARY -> LibraryScreen( + files = files, + on_file_click = { file -> + val vm = PlayerViewModel(player, repo); vm.load_file(file); player_vm = vm; screen = Screen.PLAYER + }, + on_import = { on_import { refresh_files() } }, + on_delete = { id -> repo.delete_file(id); refresh_files() }, + ) + Screen.PLAYER -> player_vm?.let { vm -> + PlayerScreen(vm = vm, on_back = { vm.release(); player_vm = null; refresh_files(); screen = Screen.LIBRARY }) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/ubi2/sonodrill/ui/library/library_screen.kt b/composeApp/src/commonMain/kotlin/ubi2/sonodrill/ui/library/library_screen.kt @@ -0,0 +1,76 @@ +package ubi2.sonodrill.ui.library + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import ubi2.sonodrill.model.AudioFileInfo +import ubi2.sonodrill.ui.player.format_time + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LibraryScreen( + files: List<AudioFileInfo>, + on_file_click: (AudioFileInfo) -> Unit, + on_import: () -> Unit, + on_delete: (String) -> Unit, +) { + var search by remember { mutableStateOf("") } + val filtered = if (search.isBlank()) files else files.filter { it.title.contains(search, ignoreCase = true) } + + Scaffold( + topBar = { TopAppBar(title = { Text("Sonodrill") }) }, + floatingActionButton = { FloatingActionButton(onClick = on_import) { Icon(Icons.Default.Add, "Import audio") } }, + ) { padding -> + Column(Modifier.fillMaxSize().padding(padding)) { + OutlinedTextField( + value = search, onValueChange = { search = it }, + placeholder = { Text("Search...") }, leadingIcon = { Icon(Icons.Default.Search, null) }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), singleLine = true, + ) + if (filtered.isEmpty()) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon(Icons.Default.MusicNote, null, modifier = Modifier.size(64.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)) + Spacer(Modifier.height(16.dp)); Text("No audio files", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(8.dp)); Text("Tap + to import", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } else { + LazyColumn(contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + items(filtered, key = { it.id }) { file -> + AudioFileItem(file, on_click = { on_file_click(file) }, on_delete = { on_delete(file.id) }) + } + } + } + } + } +} + +@Composable +private fun AudioFileItem(file: AudioFileInfo, on_click: () -> Unit, on_delete: () -> Unit) { + Card(modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)).clickable(onClick = on_click)) { + Row(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.AudioFile, null, modifier = Modifier.size(40.dp), tint = MaterialTheme.colorScheme.primary) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text(file.title, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyLarge) + file.duration_ms?.let { Text(format_time(it), fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) } + } + if (file.last_position_ms > 0 && file.duration_ms != null && file.duration_ms > 0) + LinearProgressIndicator(progress = { file.last_position_ms.toFloat() / file.duration_ms }, modifier = Modifier.width(40.dp).padding(end = 8.dp)) + IconButton(onClick = on_delete) { Icon(Icons.Default.Delete, "Delete", modifier = Modifier.size(20.dp)) } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/ubi2/sonodrill/ui/player/player_screen.kt b/composeApp/src/commonMain/kotlin/ubi2/sonodrill/ui/player/player_screen.kt @@ -0,0 +1,358 @@ +package ubi2.sonodrill.ui.player + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import ubi2.sonodrill.model.LoopState +import ubi2.sonodrill.model.MarkInfo + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PlayerScreen(vm: PlayerViewModel, on_back: () -> Unit) { + val position by vm.position.collectAsState() + val duration by vm.duration.collectAsState() + val playing by vm.is_playing.collectAsState() + val speed by vm.speed.collectAsState() + val marks by vm.marks.collectAsState() + val loop_state by vm.loop_state.collectAsState() + val file by vm.current_file.collectAsState() + val waveform by vm.waveform.collectAsState() + + var show_speed_sheet by remember { mutableStateOf(false) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(file?.title ?: "", maxLines = 1, overflow = TextOverflow.Ellipsis) }, + navigationIcon = { + IconButton(onClick = on_back) { Icon(Icons.Default.ArrowBack, "Back") } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier.fillMaxSize().padding(padding), + ) { + // Large progress area with marks + Box(modifier = Modifier.fillMaxWidth().weight(1f).padding(horizontal = 8.dp)) { + ProgressTimeline( + position_ms = position, + duration_ms = duration, + marks = marks, + loop_state = loop_state, + waveform = waveform, + on_seek = { tap_ms -> + vm.seek(tap_ms) + // If mark loop is active and tap is outside current loop, re-loop around tap position + val ls = loop_state + if (ls.enabled && ls.mark_a != null && ls.mark_a!!.id != "_full_a") { + val a = ls.mark_a!!.position_ms + val b = ls.mark_b?.position_ms ?: a + if (tap_ms < a || tap_ms > b) { + vm.loop_around(tap_ms) + } + } + }, + ) + } + + // Time display + Row( + Modifier.fillMaxWidth().padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(format_time(position), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold) + Text(format_time(duration), style = MaterialTheme.typography.bodyMedium) + } + + Spacer(Modifier.height(8.dp)) + + // Transport controls + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = { vm.skip(-15_000) }) { + Icon(Icons.Default.FastRewind, "-15s", modifier = Modifier.size(24.dp)) + } + IconButton(onClick = { vm.prev_mark() }) { + Icon(Icons.Default.SkipPrevious, "Previous mark") + } + FilledIconButton( + onClick = { vm.toggle_playback() }, + modifier = Modifier.size(64.dp), + ) { + Icon( + if (playing) Icons.Default.Pause else Icons.Default.PlayArrow, + contentDescription = if (playing) "Pause" else "Play", + modifier = Modifier.size(32.dp), + ) + } + IconButton(onClick = { vm.next_mark() }) { + Icon(Icons.Default.SkipNext, "Next mark") + } + IconButton(onClick = { vm.skip(15_000) }) { + Icon(Icons.Default.FastForward, "+15s", modifier = Modifier.size(24.dp)) + } + } + + // Action buttons row + Row( + Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton(onClick = { show_speed_sheet = true }) { + Text("${format_speed(speed)}x") + } + OutlinedIconButton(onClick = { vm.add_mark() }) { + Icon(Icons.Default.BookmarkAdd, "Add mark", modifier = Modifier.size(22.dp)) + } + OutlinedIconButton(onClick = { vm.delete_prev_mark() }, enabled = marks.isNotEmpty()) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy((-4).dp)) { + Icon(Icons.Default.ArrowBack, null, modifier = Modifier.size(14.dp)) + Icon(Icons.Default.BookmarkRemove, null, modifier = Modifier.size(22.dp)) + } + } + OutlinedIconButton(onClick = { vm.delete_next_mark() }, enabled = marks.isNotEmpty()) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy((-4).dp)) { + Icon(Icons.Default.BookmarkRemove, null, modifier = Modifier.size(22.dp)) + Icon(Icons.Default.ArrowForward, null, modifier = Modifier.size(14.dp)) + } + } + } + + // Loop buttons row + Row( + Modifier.fillMaxWidth().padding(horizontal = 16.dp).padding(bottom = 8.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + val mark_loop_active = loop_state.enabled && loop_state.mark_a != null && loop_state.mark_a!!.id != "_full_a" + val full_loop_active = loop_state.enabled && loop_state.mark_a?.id == "_full_a" + + if (mark_loop_active) { + FilledIconButton( + onClick = { vm.toggle_mark_loop() }, + enabled = marks.size >= 2, + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy((-2).dp)) { + Icon(Icons.Default.Repeat, "Mark Loop", modifier = Modifier.size(22.dp)) + Icon(Icons.Default.Bookmark, null, modifier = Modifier.size(14.dp)) + } + } + } else { + OutlinedIconButton( + onClick = { vm.toggle_mark_loop() }, + enabled = marks.size >= 2, + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy((-2).dp)) { + Icon(Icons.Default.Repeat, "Mark Loop", modifier = Modifier.size(22.dp)) + Icon(Icons.Default.Bookmark, null, modifier = Modifier.size(14.dp)) + } + } + } + + if (full_loop_active) { + FilledIconButton(onClick = { vm.toggle_full_loop() }) { + Icon(Icons.Default.Repeat, "Full Loop", modifier = Modifier.size(22.dp)) + } + } else { + OutlinedIconButton(onClick = { vm.toggle_full_loop() }) { + Icon(Icons.Default.Repeat, "Full Loop", modifier = Modifier.size(22.dp)) + } + } + } + + Spacer(Modifier.height(8.dp)) + } + } + + // Speed bottom sheet + if (show_speed_sheet) { + ModalBottomSheet(onDismissRequest = { show_speed_sheet = false }) { + SpeedControl(speed) { vm.set_speed(it) } + Spacer(Modifier.height(32.dp)) + } + } +} + +@Composable +private fun ProgressTimeline( + position_ms: Long, + duration_ms: Long, + marks: List<MarkInfo>, + loop_state: LoopState, + waveform: FloatArray, + on_seek: (Long) -> Unit, +) { + val primary = MaterialTheme.colorScheme.primary + val surface_variant = MaterialTheme.colorScheme.surfaceVariant + val on_surface = MaterialTheme.colorScheme.onSurface + val mark_color = Color(0xFFFF6B35) + val loop_color = MaterialTheme.colorScheme.primaryContainer + + val rows = 6 + val row_duration = if (duration_ms > 0) duration_ms.toFloat() / rows else 1f + val samples_per_row = if (waveform.isNotEmpty()) waveform.size.toFloat() / rows else 0f + + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + for (row in 0 until rows) { + val row_start_ms = row * row_duration + val row_end_ms = (row + 1) * row_duration + val row_progress = when { + position_ms >= row_end_ms -> 1f + position_ms <= row_start_ms -> 0f + else -> (position_ms - row_start_ms) / (row_end_ms - row_start_ms) + } + val row_marks = marks.filter { it.position_ms >= row_start_ms && it.position_ms < row_end_ms } + val is_active_row = position_ms >= row_start_ms && position_ms < row_end_ms + val wave_start = (row * samples_per_row).toInt() + val wave_end = ((row + 1) * samples_per_row).toInt().coerceAtMost(waveform.size) + + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .clip(RoundedCornerShape(6.dp)) + .background(surface_variant) + .pointerInput(row, duration_ms) { + detectTapGestures { offset -> + val fraction = (offset.x / size.width).coerceIn(0f, 1f) + val tap_ms = (row_start_ms + fraction * (row_end_ms - row_start_ms)).toLong() + on_seek(tap_ms) + } + } + ) { + Canvas(modifier = Modifier.fillMaxSize()) { + val w = size.width + val h = size.height + val mid_y = h / 2f + + // Loop region highlight + if (loop_state.enabled && loop_state.mark_a != null && loop_state.mark_b != null) { + val a = loop_state.mark_a!!.position_ms.toFloat() + val b = loop_state.mark_b!!.position_ms.toFloat() + val ls = ((a - row_start_ms) / (row_end_ms - row_start_ms)).coerceIn(0f, 1f) + val le = ((b - row_start_ms) / (row_end_ms - row_start_ms)).coerceIn(0f, 1f) + if (le > ls && a < row_end_ms && b > row_start_ms) { + drawRect( + color = loop_color, + topLeft = Offset(ls * w, 0f), + size = androidx.compose.ui.geometry.Size((le - ls) * w, h), + ) + } + } + + // Waveform or simple progress + val row_samples = wave_end - wave_start + if (row_samples > 0) { + val bar_width = w / row_samples + for (i in 0 until row_samples) { + val amp = waveform[wave_start + i].coerceIn(0f, 1f) + val bar_h = amp * h * 0.9f + val x = i * bar_width + val frac = i.toFloat() / row_samples + val color = if (frac <= row_progress) primary else primary.copy(alpha = 0.3f) + drawRect( + color = color, + topLeft = Offset(x, mid_y - bar_h / 2f), + size = androidx.compose.ui.geometry.Size(bar_width.coerceAtLeast(1f), bar_h.coerceAtLeast(1f)), + ) + } + } else if (row_progress > 0f) { + drawRect( + color = primary.copy(alpha = 0.3f), + topLeft = Offset.Zero, + size = androidx.compose.ui.geometry.Size(row_progress * w, h), + ) + } + + // Marks as vertical lines + for (mark in row_marks) { + val mark_frac = (mark.position_ms - row_start_ms) / (row_end_ms - row_start_ms) + val x = mark_frac * w + drawLine(color = mark_color, start = Offset(x, 0f), end = Offset(x, h), strokeWidth = 3f) + drawCircle(color = mark_color, radius = 5f, center = Offset(x, 5f)) + } + + // Playhead cursor + if (is_active_row) { + val cursor_x = row_progress * w + drawLine(color = on_surface, start = Offset(cursor_x, 0f), end = Offset(cursor_x, h), strokeWidth = 2.5f) + } + } + + // Row time label + Text( + text = format_time(row_start_ms.toLong()), + fontSize = 10.sp, + color = on_surface.copy(alpha = 0.5f), + modifier = Modifier.padding(start = 4.dp, top = 2.dp), + ) + } + } + } +} + +@Composable +private fun SpeedControl(speed: Float, on_change: (Float) -> Unit) { + val presets = listOf(0.5f, 0.75f, 1f, 1.5f, 2f) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(horizontal = 16.dp), + ) { + Text("Speed: ${format_speed(speed)}x", fontWeight = FontWeight.Bold, fontSize = 18.sp) + Spacer(Modifier.height(8.dp)) + Slider( + value = speed, + onValueChange = on_change, + valueRange = 0.5f..2f, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) { + presets.forEach { p -> + FilterChip( + selected = speed == p, + onClick = { on_change(p) }, + label = { Text("${p}x", fontSize = 12.sp) }, + ) + } + } + } +} + +fun format_time(ms: Long): String { + val s = ms / 1000; val h = s / 3600; val m = (s % 3600) / 60; val sec = s % 60 + return if (h > 0) "$h:${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}" + else "$m:${sec.toString().padStart(2, '0')}" +} + +private fun format_speed(speed: Float): String { + val rounded = (speed * 100).toLong() + val whole = rounded / 100 + val frac = rounded % 100 + return "$whole.${frac.toString().padStart(2, '0')}" +} diff --git a/composeApp/src/commonMain/kotlin/ubi2/sonodrill/ui/player/player_view_model.kt b/composeApp/src/commonMain/kotlin/ubi2/sonodrill/ui/player/player_view_model.kt @@ -0,0 +1,178 @@ +package ubi2.sonodrill.ui.player + +import ubi2.sonodrill.audio.AudioPlayer +import ubi2.sonodrill.audio.LoopController +import ubi2.sonodrill.audio.extract_waveform +import ubi2.sonodrill.data.AudioRepository +import ubi2.sonodrill.model.AudioFileInfo +import ubi2.sonodrill.model.LoopState +import ubi2.sonodrill.model.MarkInfo +import ubi2.sonodrill.model.current_time_ms +import ubi2.sonodrill.model.generate_uuid +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +class PlayerViewModel( + private val player: AudioPlayer, + private val repo: AudioRepository, +) { + val loop = LoopController(player) + val position = player.current_position_ms + val duration = player.duration_ms + val is_playing = player.is_playing + val speed = player.playback_speed + val loop_state = loop.state + + private val _current_file = MutableStateFlow<AudioFileInfo?>(null) + val current_file: StateFlow<AudioFileInfo?> = _current_file.asStateFlow() + + private val _marks = MutableStateFlow<List<MarkInfo>>(emptyList()) + val marks: StateFlow<List<MarkInfo>> = _marks.asStateFlow() + + private val _waveform = MutableStateFlow(FloatArray(0)) + val waveform: StateFlow<FloatArray> = _waveform.asStateFlow() + + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + private var save_job: Job? = null + + fun load_file(file: AudioFileInfo) { + _current_file.value = file + player.load(file.uri) + _marks.value = repo.get_marks(file.id) + if (file.last_position_ms > 0) player.seek(file.last_position_ms) + if (file.last_speed != 1f) player.set_speed(file.last_speed) + scope.launch { _waveform.value = extract_waveform(file.uri, 600) } + start_auto_save() + } + + fun toggle_playback() = player.toggle() + fun seek(ms: Long) = player.seek(ms) + fun set_speed(rate: Float) = player.set_speed(rate) + fun skip(delta_ms: Long) = player.skip(delta_ms) + + fun add_mark(label: String? = null) { + val file = _current_file.value ?: return + val mark = MarkInfo( + id = generate_uuid(), + file_id = file.id, + position_ms = position.value, + label = label, + created_at = current_time_ms(), + ) + repo.upsert_mark(mark) + _marks.value = repo.get_marks(file.id) + } + + fun delete_mark(id: String) { + val file = _current_file.value ?: return + repo.delete_mark(id) + _marks.value = repo.get_marks(file.id) + if (loop_state.value.mark_a?.id == id || loop_state.value.mark_b?.id == id) loop.clear() + } + + fun set_loop_marks(a: MarkInfo, b: MarkInfo) = loop.set_loop(a, b) + fun toggle_loop() = loop.toggle() + fun clear_loop() = loop.clear() + fun seek_to_mark(mark: MarkInfo) = player.seek(mark.position_ms) + + fun next_mark() { + val pos = position.value + val ls = loop_state.value + // If mark loop is active, jump to the next loop region + if (ls.enabled && ls.mark_a != null && ls.mark_a!!.id != "_full_a" && ls.mark_b != null) { + val current_end = ls.mark_b!! + val sorted = _marks.value.sortedBy { it.position_ms } + val next_start = sorted.firstOrNull { it.position_ms >= current_end.position_ms && it.id != current_end.id } + if (next_start != null) { + val next_end = sorted.firstOrNull { it.position_ms > next_start.position_ms } + if (next_end != null) { + loop.set_loop(next_start, next_end) + player.seek(next_start.position_ms) + return + } + } + // No next loop region available, disable loop + loop.clear() + return + } + _marks.value.firstOrNull { it.position_ms > pos + 500 }?.let { seek_to_mark(it) } + } + + fun prev_mark() { + val pos = position.value + _marks.value.lastOrNull { it.position_ms < pos - 500 }?.let { seek_to_mark(it) } + } + + fun delete_prev_mark() { + val file = _current_file.value ?: return + val pos = position.value + val prev = _marks.value.lastOrNull { it.position_ms <= pos } ?: return + repo.delete_mark(prev.id) + _marks.value = repo.get_marks(file.id) + if (loop_state.value.mark_a?.id == prev.id || loop_state.value.mark_b?.id == prev.id) loop.clear() + } + + fun delete_next_mark() { + val file = _current_file.value ?: return + val pos = position.value + val next = _marks.value.firstOrNull { it.position_ms > pos } ?: return + repo.delete_mark(next.id) + _marks.value = repo.get_marks(file.id) + if (loop_state.value.mark_a?.id == next.id || loop_state.value.mark_b?.id == next.id) loop.clear() + } + + fun loop_around(position_ms: Long) { + val sorted = _marks.value.sortedBy { it.position_ms } + val prev = sorted.lastOrNull { it.position_ms <= position_ms } + val next = sorted.firstOrNull { it.position_ms > position_ms } + if (prev != null && next != null) { + loop.set_loop(prev, next) + } + } + + fun toggle_mark_loop() { + if (loop_state.value.enabled && loop_state.value.mark_a?.id != "_full_a") { + loop.clear() + return + } + val pos = position.value + val sorted = _marks.value.sortedBy { it.position_ms } + val prev = sorted.lastOrNull { it.position_ms <= pos + 500 } + val next = sorted.firstOrNull { it.position_ms > pos - 500 && it != prev } + if (prev != null && next != null) { + loop.set_loop(prev, next) + } + } + + fun toggle_full_loop() { + val dur = duration.value + if (dur <= 0) return + if (loop_state.value.enabled) { + loop.clear() + } else { + val a = MarkInfo(id = "_full_a", file_id = "", position_ms = 0, created_at = 0) + val b = MarkInfo(id = "_full_b", file_id = "", position_ms = dur, created_at = 0) + loop.set_loop(a, b) + } + } + + private fun start_auto_save() { + save_job?.cancel() + save_job = scope.launch { + while (isActive) { + delay(5000) + val file = _current_file.value ?: continue + repo.update_position(file.id, position.value, speed.value) + } + } + } + + fun release() { + val file = _current_file.value + if (file != null) repo.update_position(file.id, position.value, speed.value) + save_job?.cancel() + loop.release() + player.release() + scope.cancel() + } +} diff --git a/composeApp/src/commonMain/sqldelight/ubi2/sonodrill/data/AudioFile.sq b/composeApp/src/commonMain/sqldelight/ubi2/sonodrill/data/AudioFile.sq @@ -0,0 +1,25 @@ +CREATE TABLE audio_file ( + id TEXT NOT NULL PRIMARY KEY, + uri TEXT NOT NULL, + title TEXT NOT NULL, + duration_ms INTEGER, + last_position_ms INTEGER NOT NULL DEFAULT 0, + last_speed REAL NOT NULL DEFAULT 1.0, + updated_at INTEGER NOT NULL +); + +select_all: +SELECT * FROM audio_file ORDER BY updated_at DESC; + +select_by_id: +SELECT * FROM audio_file WHERE id = ?; + +upsert: +INSERT OR REPLACE INTO audio_file(id, uri, title, duration_ms, last_position_ms, last_speed, updated_at) +VALUES (?, ?, ?, ?, ?, ?, ?); + +update_position: +UPDATE audio_file SET last_position_ms = ?, last_speed = ?, updated_at = ? WHERE id = ?; + +delete_by_id: +DELETE FROM audio_file WHERE id = ?; diff --git a/composeApp/src/commonMain/sqldelight/ubi2/sonodrill/data/Mark.sq b/composeApp/src/commonMain/sqldelight/ubi2/sonodrill/data/Mark.sq @@ -0,0 +1,22 @@ +CREATE TABLE mark ( + id TEXT NOT NULL PRIMARY KEY, + file_id TEXT NOT NULL, + position_ms INTEGER NOT NULL, + label TEXT, + color TEXT NOT NULL DEFAULT "#FF6B35", + created_at INTEGER NOT NULL, + FOREIGN KEY(file_id) REFERENCES audio_file(id) ON DELETE CASCADE +); + +select_by_file: +SELECT * FROM mark WHERE file_id = ? ORDER BY position_ms ASC; + +upsert: +INSERT OR REPLACE INTO mark(id, file_id, position_ms, label, color, created_at) +VALUES (?, ?, ?, ?, ?, ?); + +delete_by_id: +DELETE FROM mark WHERE id = ?; + +delete_by_file: +DELETE FROM mark WHERE file_id = ?; diff --git a/composeApp/src/iosMain/kotlin/ubi2/sonodrill/audio/ios_audio_player.kt b/composeApp/src/iosMain/kotlin/ubi2/sonodrill/audio/ios_audio_player.kt @@ -0,0 +1,109 @@ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) +package ubi2.sonodrill.audio + +import kotlinx.cinterop.* +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import platform.AVFAudio.* +import platform.Foundation.NSError +import platform.Foundation.NSURL + +class IosAudioPlayer : AudioPlayer { + private var engine: AVAudioEngine? = null + private var player_node: AVAudioPlayerNode? = null + private var time_pitch: AVAudioUnitTimePitch? = null + private var audio_file: AVAudioFile? = null + private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + + private val _position = MutableStateFlow(0L) + override val current_position_ms: StateFlow<Long> = _position.asStateFlow() + private val _duration = MutableStateFlow(0L) + override val duration_ms: StateFlow<Long> = _duration.asStateFlow() + private val _playing = MutableStateFlow(false) + override val is_playing: StateFlow<Boolean> = _playing.asStateFlow() + private val _speed = MutableStateFlow(1f) + override val playback_speed: StateFlow<Float> = _speed.asStateFlow() + + private var position_job: Job? = null + private var seek_offset_ms: Long = 0 + + override fun load(uri: String) { + release_engine() + // Configure audio session for playback + try { + val session = AVAudioSession.sharedInstance() + session.setCategory(AVAudioSessionCategoryPlayback, error = null) + session.setActive(true, error = null) + } catch (_: Exception) { + println("Sonodrill: failed to configure audio session") + } + val url = NSURL.fileURLWithPath(uri) + val file: AVAudioFile + try { + file = AVAudioFile(forReading = url, error = null) ?: return + } catch (_: Exception) { + println("Sonodrill: failed to open audio file: $uri") + return + } + val format = file.processingFormat + if (format.sampleRate == 0.0) { + println("Sonodrill: invalid audio format for: $uri") + return + } + audio_file = file + _duration.value = ((file.length.toDouble() / format.sampleRate) * 1000).toLong() + + val eng = AVAudioEngine() + val node = AVAudioPlayerNode() + val pitch = AVAudioUnitTimePitch() + eng.attachNode(node); eng.attachNode(pitch) + eng.connect(node, to = pitch, format = format) + eng.connect(pitch, to = eng.mainMixerNode, format = format) + engine = eng; player_node = node; time_pitch = pitch; pitch.rate = _speed.value + eng.prepare(); eng.startAndReturnError(null) + seek_offset_ms = 0 + node.scheduleFile(file, atTime = null, completionHandler = null) + } + + override fun play() { player_node?.play(); _playing.value = true; start_position_updates() } + override fun pause() { player_node?.pause(); _playing.value = false; stop_position_updates() } + override fun toggle() { if (_playing.value) pause() else play() } + + override fun seek(position_ms: Long) { + val file = audio_file ?: return; val node = player_node ?: return + val was_playing = _playing.value; node.stop() + val frame = (position_ms.toDouble() / 1000.0 * file.processingFormat.sampleRate).toLong() + val remaining = file.length - frame + if (remaining > 0) { + node.scheduleSegment(file, startingFrame = frame, frameCount = remaining.toUInt(), atTime = null, completionHandler = null) + } + seek_offset_ms = position_ms + _position.value = position_ms + if (was_playing) node.play() + } + + override fun set_speed(rate: Float) { _speed.value = rate; time_pitch?.rate = rate } + override fun skip(delta_ms: Long) = seek((_position.value + delta_ms).coerceIn(0, _duration.value)) + override fun release() { release_engine(); scope.cancel() } + + private fun release_engine() { stop_position_updates(); player_node?.stop(); engine?.stop(); engine = null; player_node = null; time_pitch = null; audio_file = null } + private fun start_position_updates() { position_job?.cancel(); position_job = scope.launch { while (isActive) { update_current_position(); delay(100) } } } + private fun stop_position_updates() { position_job?.cancel(); position_job = null } + + private fun update_current_position() { + val node = player_node ?: return; val file = audio_file ?: return + val time = node.lastRenderTime ?: return; val pt = node.playerTimeForNodeTime(time) ?: return + val elapsed_ms = ((pt.sampleTime.toDouble() / file.processingFormat.sampleRate) * 1000).toLong() + _position.value = seek_offset_ms + elapsed_ms + } +} + +actual fun create_audio_player(): AudioPlayer = IosAudioPlayer() + +actual fun extract_waveform(uri: String, num_samples: Int): FloatArray { + // AVAudioPCMBuffer float pointer APIs not available in Kotlin/Native cinterop. + // Waveform will use fallback progress display. + return FloatArray(0) +} diff --git a/composeApp/src/iosMain/kotlin/ubi2/sonodrill/data/ios_database.kt b/composeApp/src/iosMain/kotlin/ubi2/sonodrill/data/ios_database.kt @@ -0,0 +1,8 @@ +package ubi2.sonodrill.data + +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.driver.native.NativeSqliteDriver + +actual class DriverFactory { + actual fun create(): SqlDriver = NativeSqliteDriver(SonodrillDatabase.Schema, "sonodrill.db") +} diff --git a/composeApp/src/iosMain/kotlin/ubi2/sonodrill/main_ios.kt b/composeApp/src/iosMain/kotlin/ubi2/sonodrill/main_ios.kt @@ -0,0 +1,100 @@ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class, kotlinx.cinterop.BetaInteropApi::class) +package ubi2.sonodrill + +import androidx.compose.ui.window.ComposeUIViewController +import platform.Foundation.* +import platform.UIKit.* +import platform.UniformTypeIdentifiers.UTTypeAudio +import platform.darwin.NSObject +import ubi2.sonodrill.audio.create_audio_player +import ubi2.sonodrill.data.AudioRepository +import ubi2.sonodrill.data.DriverFactory +import ubi2.sonodrill.data.create_database +import ubi2.sonodrill.model.AudioFileInfo +import ubi2.sonodrill.model.current_time_ms +import ubi2.sonodrill.model.generate_uuid +import ubi2.sonodrill.ui.SonodrillApp + +// prevent delegate from being garbage collected +private var active_delegate: AudioPickerDelegate? = null + +fun main_view_controller(): UIViewController { + val db = create_database(DriverFactory()) + val repo = AudioRepository(db) + val player = create_audio_player() + + val controller = ComposeUIViewController { + SonodrillApp( + player = player, + repo = repo, + on_import = { on_done -> + show_audio_picker(repo, on_done) + }, + ) + } + + return controller +} + +private fun show_audio_picker(repo: AudioRepository, on_done: () -> Unit) { + val root = UIApplication.sharedApplication.keyWindow?.rootViewController ?: return + val picker = UIDocumentPickerViewController(forOpeningContentTypes = listOf(UTTypeAudio)) + picker.allowsMultipleSelection = true + + val delegate = AudioPickerDelegate(repo, on_done) + active_delegate = delegate + picker.delegate = delegate + + root.presentViewController(picker, animated = true, completion = null) +} + +internal class AudioPickerDelegate( + private val repo: AudioRepository, + private val on_done: () -> Unit, +) : NSObject(), UIDocumentPickerDelegateProtocol { + + override fun documentPicker(controller: UIDocumentPickerViewController, didPickDocumentsAtURLs: List<*>) { + val file_manager = NSFileManager.defaultManager + val docs_dir = NSSearchPathForDirectoriesInDomains( + NSDocumentDirectory, NSUserDomainMask, true + ).firstOrNull() as? String ?: return + + val audio_dir = "$docs_dir/audio" + if (!file_manager.fileExistsAtPath(audio_dir)) { + file_manager.createDirectoryAtPath(audio_dir, withIntermediateDirectories = true, attributes = null, error = null) + } + + for (url in didPickDocumentsAtURLs) { + val nsurl = url as? NSURL ?: continue + nsurl.startAccessingSecurityScopedResource() + try { + val file_name = nsurl.lastPathComponent ?: "unknown" + val dest_path = "$audio_dir/$file_name" + val dest_url = NSURL.fileURLWithPath(dest_path) + + if (file_manager.fileExistsAtPath(dest_path)) { + file_manager.removeItemAtPath(dest_path, error = null) + } + + file_manager.copyItemAtURL(nsurl, toURL = dest_url, error = null) + + val title = file_name.substringBeforeLast(".") + val info = AudioFileInfo( + id = generate_uuid(), + uri = dest_path, + title = title, + updated_at = current_time_ms(), + ) + repo.upsert_file(info) + } finally { + nsurl.stopAccessingSecurityScopedResource() + } + } + active_delegate = null + on_done() + } + + override fun documentPickerWasCancelled(controller: UIDocumentPickerViewController) { + active_delegate = null + } +} diff --git a/composeApp/src/iosMain/kotlin/ubi2/sonodrill/model/ios_platform.kt b/composeApp/src/iosMain/kotlin/ubi2/sonodrill/model/ios_platform.kt @@ -0,0 +1,9 @@ +package ubi2.sonodrill.model + +import platform.Foundation.NSDate +import platform.Foundation.NSUUID +import platform.Foundation.timeIntervalSince1970 + +actual fun current_time_ms(): Long = (NSDate().timeIntervalSince1970 * 1000).toLong() +actual fun generate_uuid(): String = NSUUID().UUIDString() +actual fun file_exists(path: String): Boolean = platform.Foundation.NSFileManager.defaultManager.fileExistsAtPath(path) diff --git a/composeApp/src/iosMain/kotlin/ubi2/sonodrill/sync/ios_sync.kt b/composeApp/src/iosMain/kotlin/ubi2/sonodrill/sync/ios_sync.kt @@ -0,0 +1,14 @@ +package ubi2.sonodrill.sync + +import ubi2.sonodrill.model.AudioFileInfo +import ubi2.sonodrill.model.MarkInfo + +class IosSyncService : SyncService { + override suspend fun push_files(files: List<AudioFileInfo>) {} + override suspend fun push_marks(marks: List<MarkInfo>) {} + override suspend fun pull_files(): List<AudioFileInfo> = emptyList() + override suspend fun pull_marks(): List<MarkInfo> = emptyList() + override suspend fun sync() {} +} + +actual fun create_sync_service(): SyncService = IosSyncService() diff --git a/gradle.properties b/gradle.properties @@ -0,0 +1,5 @@ +org.gradle.jvmargs=-Xmx2048M -Dfile.encoding=UTF-8 +android.useAndroidX=true +android.nonTransitiveRClass=true +kotlin.code.style=official +kotlin.mpp.androidSourceSetLayoutVersion=2 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml @@ -0,0 +1,34 @@ +[versions] +agp = "8.2.2" +kotlin = "2.1.0" +compose-multiplatform = "1.7.3" +sqldelight = "2.0.2" +exoplayer = "1.5.0" +coroutines = "1.9.0" +koin = "4.0.0" +navigation = "2.8.0-alpha10" + +[libraries] +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } + +sqldelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqldelight" } +sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" } +sqldelight-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" } +sqldelight-native = { module = "app.cash.sqldelight:native-driver", version.ref = "sqldelight" } + +exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "exoplayer" } +exoplayer-ui = { module = "androidx.media3:media3-ui", version.ref = "exoplayer" } + +koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } +koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" } + +navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigation" } + +[plugins] +androidApplication = { id = "com.android.application", version.ref = "agp" } +androidLibrary = { id = "com.android.library", version.ref = "agp" } +kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" } +composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar Binary files differ. diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "icon_1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iosApp/Assets.xcassets/AppIcon.appiconset/icon_1024.png b/iosApp/Assets.xcassets/AppIcon.appiconset/icon_1024.png Binary files differ. diff --git a/iosApp/Assets.xcassets/AppIcon.appiconset/icon_128.png b/iosApp/Assets.xcassets/AppIcon.appiconset/icon_128.png Binary files differ. diff --git a/iosApp/Assets.xcassets/AppIcon.appiconset/icon_20.png b/iosApp/Assets.xcassets/AppIcon.appiconset/icon_20.png Binary files differ. diff --git a/iosApp/Assets.xcassets/AppIcon.appiconset/icon_256.png b/iosApp/Assets.xcassets/AppIcon.appiconset/icon_256.png Binary files differ. diff --git a/iosApp/Assets.xcassets/AppIcon.appiconset/icon_32.png b/iosApp/Assets.xcassets/AppIcon.appiconset/icon_32.png Binary files differ. diff --git a/iosApp/Assets.xcassets/AppIcon.appiconset/icon_40.png b/iosApp/Assets.xcassets/AppIcon.appiconset/icon_40.png Binary files differ. diff --git a/iosApp/Assets.xcassets/AppIcon.appiconset/icon_512.png b/iosApp/Assets.xcassets/AppIcon.appiconset/icon_512.png Binary files differ. diff --git a/iosApp/Assets.xcassets/AppIcon.appiconset/icon_64.png b/iosApp/Assets.xcassets/AppIcon.appiconset/icon_64.png Binary files differ. diff --git a/iosApp/Assets.xcassets/Contents.json b/iosApp/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iosApp/Configuration/Config.xcconfig b/iosApp/Configuration/Config.xcconfig @@ -0,0 +1,3 @@ +TEAM_ID = +BUNDLE_ID = ubi2.sonodrill +APP_NAME = Sonodrill diff --git a/iosApp/ContentView.swift b/iosApp/ContentView.swift @@ -0,0 +1,16 @@ +import SwiftUI +import ComposeApp + +struct ContentView: View { + var body: some View { + ComposeViewControllerRepresentable() + .ignoresSafeArea() + } +} + +struct ComposeViewControllerRepresentable: UIViewControllerRepresentable { + func makeUIViewController(context: Context) -> UIViewController { + Main_iosKt.main_view_controller() + } + func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} +} diff --git a/iosApp/Info.plist b/iosApp/Info.plist @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>CADisableMinimumFrameDuration</key> + <true/> + <key>CADisableMinimumFrameDurationOnPhone</key> + <true/> + <key>CFBundleDevelopmentRegion</key> + <string>$(DEVELOPMENT_LANGUAGE)</string> + <key>CFBundleExecutable</key> + <string>$(EXECUTABLE_NAME)</string> + <key>CFBundleIdentifier</key> + <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> + <key>CFBundleInfoDictionaryVersion</key> + <string>6.0</string> + <key>CFBundleName</key> + <string>$(PRODUCT_NAME)</string> + <key>CFBundlePackageType</key> + <string>APPL</string> + <key>CFBundleShortVersionString</key> + <string>1.0</string> + <key>CFBundleVersion</key> + <string>1</string> + <key>LSRequiresIPhoneOS</key> + <true/> + <key>UIApplicationSceneManifest</key> + <dict> + <key>UIApplicationSupportsMultipleScenes</key> + <false/> + </dict> + <key>UILaunchScreen</key> + <dict/> + <key>UISupportedInterfaceOrientations</key> + <array> + <string>UIInterfaceOrientationPortrait</string> + <string>UIInterfaceOrientationLandscapeLeft</string> + <string>UIInterfaceOrientationLandscapeRight</string> + </array> +</dict> +</plist> diff --git a/iosApp/SonodrillApp.swift b/iosApp/SonodrillApp.swift @@ -0,0 +1,10 @@ +import SwiftUI + +@main +struct SonodrillApplication: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -0,0 +1,368 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 63; + objects = { + +/* Begin PBXBuildFile section */ + 22EA1CB33A17DCC6FC82406D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F543015C899423D4B01CEB2E /* Assets.xcassets */; }; + 2E8A151C3EFF8D2645EFFBFA /* SonodrillApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = E74C38E65621F5192E1B68D2 /* SonodrillApp.swift */; }; + 56B0305B5D3847FC6B326502 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8509E5A49E4A5CAAEB3ADB /* ContentView.swift */; }; + 5ADAC7381145BCE241DC42A4 /* iosApp.xcodeproj in Resources */ = {isa = PBXBuildFile; fileRef = 8395CD5B070D5A2FB485F0DA /* iosApp.xcodeproj */; }; + F7C33B50A94DA01F2E82C280 /* libsqlite3.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 245434685F34A9A15B1AC406 /* libsqlite3.tbd */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 245434685F34A9A15B1AC406 /* libsqlite3.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libsqlite3.tbd; path = usr/lib/libsqlite3.tbd; sourceTree = SDKROOT; }; + 8395CD5B070D5A2FB485F0DA /* iosApp.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; path = iosApp.xcodeproj; sourceTree = "<group>"; }; + 86038AE1032E284E79B32AD6 /* Sonodrill.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Sonodrill.app; sourceTree = BUILT_PRODUCTS_DIR; }; + CE8509E5A49E4A5CAAEB3ADB /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; }; + E74C38E65621F5192E1B68D2 /* SonodrillApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SonodrillApp.swift; sourceTree = "<group>"; }; + F543015C899423D4B01CEB2E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + EF8A6E9BFD470986D9D5C9B5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + F7C33B50A94DA01F2E82C280 /* libsqlite3.tbd in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 1DB4B685CAEE8BEFEBBAEF94 /* iosApp */ = { + isa = PBXGroup; + children = ( + F543015C899423D4B01CEB2E /* Assets.xcassets */, + CE8509E5A49E4A5CAAEB3ADB /* ContentView.swift */, + 8395CD5B070D5A2FB485F0DA /* iosApp.xcodeproj */, + E74C38E65621F5192E1B68D2 /* SonodrillApp.swift */, + ); + name = iosApp; + path = .; + sourceTree = "<group>"; + }; + 3666012298E772D226418813 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 245434685F34A9A15B1AC406 /* libsqlite3.tbd */, + ); + name = Frameworks; + sourceTree = "<group>"; + }; + 38B69C3F79CD97858CF7B609 = { + isa = PBXGroup; + children = ( + 1DB4B685CAEE8BEFEBBAEF94 /* iosApp */, + 3666012298E772D226418813 /* Frameworks */, + 94C98B8B3A2BFC1966447269 /* Products */, + ); + sourceTree = "<group>"; + }; + 5FF97F622F7C9DF20050BA4C /* Products */ = { + isa = PBXGroup; + name = Products; + sourceTree = "<group>"; + }; + 94C98B8B3A2BFC1966447269 /* Products */ = { + isa = PBXGroup; + children = ( + 86038AE1032E284E79B32AD6 /* Sonodrill.app */, + ); + name = Products; + sourceTree = "<group>"; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 78B53CC79860CBFC2F5543C6 /* iosApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = 29DCA9F0B3A40325067EB163 /* Build configuration list for PBXNativeTarget "iosApp" */; + buildPhases = ( + B80CB09103FF423E9A215DEE /* Build Kotlin Framework */, + 297B59D9CE634556D6007C40 /* Sources */, + 1623B02D10928C8101C1F806 /* Resources */, + EF8A6E9BFD470986D9D5C9B5 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = iosApp; + packageProductDependencies = ( + ); + productName = iosApp; + productReference = 86038AE1032E284E79B32AD6 /* Sonodrill.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 242FB5348943441C388468CE /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + }; + buildConfigurationList = FF7CA1CA754D017066F1822F /* Build configuration list for PBXProject "iosApp" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = 38B69C3F79CD97858CF7B609; + minimizedProjectReferenceProxies = 1; + projectDirPath = ""; + projectReferences = ( + { + ProductGroup = 5FF97F622F7C9DF20050BA4C /* Products */; + ProjectRef = 8395CD5B070D5A2FB485F0DA /* iosApp.xcodeproj */; + }, + ); + projectRoot = ""; + targets = ( + 78B53CC79860CBFC2F5543C6 /* iosApp */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 1623B02D10928C8101C1F806 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 22EA1CB33A17DCC6FC82406D /* Assets.xcassets in Resources */, + 5ADAC7381145BCE241DC42A4 /* iosApp.xcodeproj in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + B80CB09103FF423E9A215DEE /* Build Kotlin Framework */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Build Kotlin Framework"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "export PATH=\"/opt/homebrew/opt/openjdk@21/bin:/opt/homebrew/bin:$PATH\"\nexport JAVA_HOME=\"/opt/homebrew/opt/openjdk@21\"\ncd \"$SRCROOT/..\"\n./gradlew :composeApp:embedAndSignAppleFrameworkForXcode\n"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 297B59D9CE634556D6007C40 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 56B0305B5D3847FC6B326502 /* ContentView.swift in Sources */, + 2E8A151C3EFF8D2645EFFBFA /* SonodrillApp.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 11814011E7D6EC822BFBC66D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = ""; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 6C824A6EB4BAD43AE2A4BB29 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = ""; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 9221A1FDC06803E8A2FC6E61 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "iPhone Developer"; + DEVELOPMENT_TEAM = 8CH9CCT4MH; + FRAMEWORK_SEARCH_PATHS = "$(SRCROOT)/../composeApp/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)"; + INFOPLIST_FILE = Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + OTHER_LINKER_FLAGS = "$(inherited) -framework ComposeApp -lsqlite3"; + PRODUCT_BUNDLE_IDENTIFIER = ubi2.sonodrill; + PRODUCT_NAME = Sonodrill; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + AF835BFF1220CA5AB5B867E4 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "iPhone Developer"; + DEVELOPMENT_TEAM = 8CH9CCT4MH; + FRAMEWORK_SEARCH_PATHS = "$(SRCROOT)/../composeApp/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)"; + INFOPLIST_FILE = Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + OTHER_LINKER_FLAGS = "$(inherited) -framework ComposeApp -lsqlite3"; + PRODUCT_BUNDLE_IDENTIFIER = ubi2.sonodrill; + PRODUCT_NAME = Sonodrill; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 29DCA9F0B3A40325067EB163 /* Build configuration list for PBXNativeTarget "iosApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 9221A1FDC06803E8A2FC6E61 /* Debug */, + AF835BFF1220CA5AB5B867E4 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + FF7CA1CA754D017066F1822F /* Build configuration list for PBXProject "iosApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 6C824A6EB4BAD43AE2A4BB29 /* Debug */, + 11814011E7D6EC822BFBC66D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + }; + rootObject = 242FB5348943441C388468CE /* Project object */; +} diff --git a/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8"?> +<Workspace + version = "1.0"> + <FileRef + location = "self:"> + </FileRef> +</Workspace> diff --git a/iosApp/iosApp.xcodeproj/project.xcworkspace/xcuserdata/jean.schurger.xcuserdatad/UserInterfaceState.xcuserstate b/iosApp/iosApp.xcodeproj/project.xcworkspace/xcuserdata/jean.schurger.xcuserdatad/UserInterfaceState.xcuserstate Binary files differ. diff --git a/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme b/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme @@ -0,0 +1,87 @@ +<?xml version="1.0" encoding="UTF-8"?> +<Scheme + LastUpgradeVersion = "1430" + version = "1.3"> + <BuildAction + parallelizeBuildables = "YES" + buildImplicitDependencies = "YES"> + <BuildActionEntries> + <BuildActionEntry + buildForTesting = "YES" + buildForRunning = "YES" + buildForProfiling = "YES" + buildForArchiving = "YES" + buildForAnalyzing = "YES"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "78B53CC79860CBFC2F5543C6" + BuildableName = "Sonodrill.app" + BlueprintName = "iosApp" + ReferencedContainer = "container:iosApp.xcodeproj"> + </BuildableReference> + </BuildActionEntry> + </BuildActionEntries> + </BuildAction> + <TestAction + buildConfiguration = "Debug" + selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" + selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + shouldUseLaunchSchemeArgsEnv = "YES"> + <MacroExpansion> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "78B53CC79860CBFC2F5543C6" + BuildableName = "Sonodrill.app" + BlueprintName = "iosApp" + ReferencedContainer = "container:iosApp.xcodeproj"> + </BuildableReference> + </MacroExpansion> + <Testables> + </Testables> + </TestAction> + <LaunchAction + buildConfiguration = "Debug" + selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" + selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + launchStyle = "0" + useCustomWorkingDirectory = "NO" + ignoresPersistentStateOnLaunch = "NO" + debugDocumentVersioning = "YES" + debugServiceExtension = "internal" + allowLocationSimulation = "YES"> + <BuildableProductRunnable + runnableDebuggingMode = "0"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "78B53CC79860CBFC2F5543C6" + BuildableName = "Sonodrill.app" + BlueprintName = "iosApp" + ReferencedContainer = "container:iosApp.xcodeproj"> + </BuildableReference> + </BuildableProductRunnable> + </LaunchAction> + <ProfileAction + buildConfiguration = "Release" + shouldUseLaunchSchemeArgsEnv = "YES" + savedToolIdentifier = "" + useCustomWorkingDirectory = "NO" + debugDocumentVersioning = "YES"> + <BuildableProductRunnable + runnableDebuggingMode = "0"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "78B53CC79860CBFC2F5543C6" + BuildableName = "Sonodrill.app" + BlueprintName = "iosApp" + ReferencedContainer = "container:iosApp.xcodeproj"> + </BuildableReference> + </BuildableProductRunnable> + </ProfileAction> + <AnalyzeAction + buildConfiguration = "Debug"> + </AnalyzeAction> + <ArchiveAction + buildConfiguration = "Release" + revealArchiveInOrganizer = "YES"> + </ArchiveAction> +</Scheme> diff --git a/iosApp/project.yml b/iosApp/project.yml @@ -0,0 +1,61 @@ +name: iosApp +options: + bundleIdPrefix: ubi2 + deploymentTarget: + iOS: "16.0" +settings: + base: + DEVELOPMENT_TEAM: "" +targets: + iosApp: + type: application + platform: iOS + sources: + - path: . + excludes: + - "Configuration" + - "project.yml" + - "Info.plist" + info: + path: Info.plist + properties: + LSRequiresIPhoneOS: true + CADisableMinimumFrameDuration: true + CADisableMinimumFrameDurationOnPhone: true + UIApplicationSceneManifest: + UIApplicationSupportsMultipleScenes: false + UILaunchScreen: {} + UISupportedInterfaceOrientations: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: ubi2.sonodrill + PRODUCT_NAME: Sonodrill + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + INFOPLIST_FILE: Info.plist + FRAMEWORK_SEARCH_PATHS: + - "$(SRCROOT)/../composeApp/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)" + OTHER_LINKER_FLAGS: + - "$(inherited)" + - "-framework" + - "ComposeApp" + - "-lsqlite3" + dependencies: + - sdk: libsqlite3.tbd + preBuildScripts: + - name: "Build Kotlin Framework" + script: | + export PATH="/opt/homebrew/opt/openjdk@21/bin:/opt/homebrew/bin:$PATH" + export JAVA_HOME="/opt/homebrew/opt/openjdk@21" + cd "$SRCROOT/.." + ./gradlew :composeApp:embedAndSignAppleFrameworkForXcode + basedOnDependencyAnalysis: false +schemes: + iosApp: + build: + targets: + iosApp: all + run: + config: Debug diff --git a/settings.gradle.kts b/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "Sonodrill" +include(":composeApp") +include(":androidApp")