sonodrill

Sonodrill, app iOS/Android d’entraînement musical (repères, boucles, vitesse), Kotlin Multiplatform + Compose
git clone https://git.schurger.org/sonodrill.git
Log | Files | Refs

commit 7861fcb3b0c26bb05b074591069bf1cbce45d93c
parent 4dcca08530ea4e8be46ddfd5417a81bfa2560efe
Author: Jean Schurger <jean@schurger.org>
Date:   Fri, 16 Oct 2026 14:34:11 -0400

Remove tip, fix data loss on iOS updates, persist zones, background audio, Android

- Remove the tip (StoreKit IAP, dialog, heart icon)
- Store audio paths relative to the app storage (iOS changes the container
  path on updates); migration 1 -> 2 rewrites existing absolute paths
- Never delete library entries automatically: missing files are flagged,
  stored files without an entry are re-added
- Re-importing the same file keeps its entry and marks; a different file
  with the same name gets a numbered name; deleting removes the stored copy
  (with a confirmation)
- Persist skip and loop zones (new zone table); store the track duration
- iOS: background audio, Now Playing + lock screen controls, handle
  interruptions, route changes and engine configuration changes (no more
  position running on in silence); play at the end restarts the track
- Loops ending at the end of the track keep looping
- Loop and skip monitors run on the main thread
- Waveform downsampled per row before drawing; timeline taps use the
  current zoom; tapping inside the active loop zone moves within it
- Play delay countdown can be cancelled; rename uses UPDATE
- Android: copy imports into app storage, display names, multiple
  selection, refresh after import, back handler, keep screen on, waveform,
  player reusable after leaving the player screen, JVM target 17
- Remove dead code and unused dependencies (sync, koin, navigation,
  compose resources, media3-ui), ignore AppleDouble files

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141Zm4UQq8PATQAG98MdNCc

Diffstat:
M.gitignore | 1+
DIAP_SETUP.md | 88-------------------------------------------------------------------------------
MandroidApp/build.gradle.kts | 8+++++++-
MandroidApp/src/main/AndroidManifest.xml | 3---
MandroidApp/src/main/kotlin/ubi2/sonodrill/MainActivity.kt | 64+++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
McomposeApp/build.gradle.kts | 5+----
McomposeApp/src/androidMain/kotlin/ubi2/sonodrill/audio/android_audio_player.kt | 127+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
AcomposeApp/src/androidMain/kotlin/ubi2/sonodrill/data/android_audio_storage.kt | 19+++++++++++++++++++
McomposeApp/src/androidMain/kotlin/ubi2/sonodrill/model/android_platform.kt | 20+++++++++++++-------
DcomposeApp/src/androidMain/kotlin/ubi2/sonodrill/sync/android_sync.kt | 14--------------
AcomposeApp/src/androidMain/kotlin/ubi2/sonodrill/ui/android_platform_ui.kt | 7+++++++
McomposeApp/src/commonMain/kotlin/ubi2/sonodrill/audio/audio_player.kt | 9++++++---
McomposeApp/src/commonMain/kotlin/ubi2/sonodrill/audio/loop_controller.kt | 16+++++++---------
AcomposeApp/src/commonMain/kotlin/ubi2/sonodrill/data/audio_storage.kt | 29+++++++++++++++++++++++++++++
McomposeApp/src/commonMain/kotlin/ubi2/sonodrill/data/repository.kt | 101+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
McomposeApp/src/commonMain/kotlin/ubi2/sonodrill/model/models.kt | 4+++-
McomposeApp/src/commonMain/kotlin/ubi2/sonodrill/model/platform.kt | 6------
DcomposeApp/src/commonMain/kotlin/ubi2/sonodrill/sync/sync_service.kt | 14--------------
McomposeApp/src/commonMain/kotlin/ubi2/sonodrill/ui/app.kt | 17+++++------------
McomposeApp/src/commonMain/kotlin/ubi2/sonodrill/ui/library/library_screen.kt | 73+++++++++++++++++--------------------------------------------------------
AcomposeApp/src/commonMain/kotlin/ubi2/sonodrill/ui/platform_ui.kt | 7+++++++
McomposeApp/src/commonMain/kotlin/ubi2/sonodrill/ui/player/player_screen.kt | 66+++++++++++++++++++++++++++++-------------------------------------
McomposeApp/src/commonMain/kotlin/ubi2/sonodrill/ui/player/player_view_model.kt | 265+++++++++++++++++++++++++++++++++++++------------------------------------------
AcomposeApp/src/commonMain/sqldelight/ubi2/sonodrill/data/1.sqm | 13+++++++++++++
McomposeApp/src/commonMain/sqldelight/ubi2/sonodrill/data/AudioFile.sq | 19+++++++++++++++++--
McomposeApp/src/commonMain/sqldelight/ubi2/sonodrill/data/Mark.sq | 11+++++++----
AcomposeApp/src/commonMain/sqldelight/ubi2/sonodrill/data/Zone.sq | 20++++++++++++++++++++
McomposeApp/src/iosMain/kotlin/ubi2/sonodrill/audio/ios_audio_player.kt | 218+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
AcomposeApp/src/iosMain/kotlin/ubi2/sonodrill/data/ios_audio_storage.kt | 33+++++++++++++++++++++++++++++++++
McomposeApp/src/iosMain/kotlin/ubi2/sonodrill/main_ios.kt | 78+++++++++++++++++++++++++++++++++---------------------------------------------
McomposeApp/src/iosMain/kotlin/ubi2/sonodrill/model/ios_platform.kt | 19++-----------------
DcomposeApp/src/iosMain/kotlin/ubi2/sonodrill/sync/ios_sync.kt | 14--------------
DcomposeApp/src/iosMain/kotlin/ubi2/sonodrill/tip/tip_provider.kt | 8--------
AcomposeApp/src/iosMain/kotlin/ubi2/sonodrill/ui/ios_platform_ui.kt | 6++++++
Mgradle/libs.versions.toml | 9++-------
MiosApp/ContentView.swift | 1-
MiosApp/Info.plist | 6+++++-
DiosApp/TipManager.swift | 65-----------------------------------------------------------------
MiosApp/iosApp.xcodeproj/project.pbxproj | 8++++----
DiosApp/iosApp.xcodeproj/project.xcworkspace/xcuserdata/jean.schurger.xcuserdatad/UserInterfaceState.xcuserstate | 0
MiosApp/project.yml | 4++++
41 files changed, 834 insertions(+), 661 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -13,3 +13,4 @@ iosApp/fastlane/README.md iosApp/fastlane/test_output/ *.ipa *.dSYM.zip +._* diff --git a/IAP_SETUP.md b/IAP_SETUP.md @@ -1,88 +0,0 @@ -# In-App Purchase Setup - Sonodrill Tip ($5) - -Product ID: `ubi2.sonodrill.tip` -Type: Non-Consumable (one-time purchase) -Price: Tier 3 ($4.99) - -## 1. Agreements (prerequisite) - -Go to https://appstoreconnect.apple.com > **Agreements, Tax, and Banking** - -- Accept the **Paid Apps** agreement -- Fill in banking info, tax info, and contact info -- Status must be **Active** before IAP works - -## 2. Create the IAP product - -1. https://appstoreconnect.apple.com > **My Apps** > **Sonodrill** -2. Sidebar: **Monetization** > **In-App Purchases** > **+** -3. Fill in: - - **Type**: Non-Consumable - - **Reference Name**: `Tip $5` - - **Product ID**: `ubi2.sonodrill.tip` -4. **Price**: Select price tier = $4.99 (Tier 3) -5. Add **Localization** (English): - - **Display Name**: `Support Sonodrill` - - **Description**: `A friendly tip to support independent development` -6. Add a **Review Screenshot** (screenshot of the tip dialog in the app) -7. Save - -## 3. Sandbox testing - -### Create a sandbox tester -1. https://appstoreconnect.apple.com > **Users and Access** > **Sandbox** > **Testers** -2. Create a test account (use a throwaway email, not a real Apple ID) - -### Test on device -1. On iPhone: **Settings** > **App Store** > scroll to bottom > **Sandbox Account** -2. Sign in with the sandbox tester account -3. Launch Sonodrill, tap the heart, tap "Tip $5" -4. Sandbox purchases are free and instant - -### Test on simulator (StoreKit Configuration file) -1. In Xcode: **File** > **New** > **File** > search **StoreKit Configuration File** -2. Name it `Sonodrill.storekit`, save in `iosApp/` -3. Add a product: - - Type: Non-Consumable - - Product ID: `ubi2.sonodrill.tip` - - Price: $4.99 - - Display Name: `Support Sonodrill` -4. In Xcode: **Product** > **Scheme** > **Edit Scheme** > **Run** > **Options** -5. Set **StoreKit Configuration** to `Sonodrill.storekit` -6. Now purchases work in the simulator without App Store Connect - -## 4. Submit for review - -The IAP must be submitted alongside an app update that uses it. -When submitting the app for review: -1. Make sure the IAP status is **Ready to Submit** -2. In the app version submission, attach the IAP under **In-App Purchases** -3. Apple will review both the app and the IAP together - -## Code reference - -- Swift StoreKit logic: `iosApp/TipManager.swift` -- Kotlin provider interface: `composeApp/src/iosMain/kotlin/ubi2/sonodrill/tip/tip_provider.kt` -- Registration: `ContentView.swift` calls `Main_iosKt.register_tip_provider()` -- UI: `library_screen.kt` - heart button + dialog - -## Environment variables for Fastlane - -``` -export UBI2_APPLE_ID=jean@ubi2.ca -export UBI2_ASC_KEY_ID=VR2XG8P2B3 -export UBI2_ASC_ISSUER_ID=cef4d644-a166-48c6-85fc-69a1899078d6 -export UBI2_ASC_KEY_PATH=/Users/jean.schurger/.appstoreconnect/AuthKey_VR2XG8P2B3.p8 -``` - -## Fastlane release - -``` -cd iosApp && fastlane release -``` - -Note: Xcode 26 beta has a bug with `exportArchive` ("Copy failed"). -Workaround: use Xcode Organizer manually: -1. `cd iosApp && fastlane release` (will archive successfully, export will fail) -2. Open Xcode > **Window** > **Organizer** -3. Select the Sonodrill archive > **Distribute App** > **App Store Connect** diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts @@ -1,3 +1,5 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + plugins { alias(libs.plugins.androidApplication) alias(libs.plugins.composeCompiler) @@ -21,10 +23,14 @@ android { buildFeatures { compose = true } } +kotlin { + compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } +} + 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") + implementation(libs.activity.compose) } diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml @@ -1,8 +1,5 @@ <?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" diff --git a/androidApp/src/main/kotlin/ubi2/sonodrill/MainActivity.kt b/androidApp/src/main/kotlin/ubi2/sonodrill/MainActivity.kt @@ -1,44 +1,86 @@ package ubi2.sonodrill -import android.content.Intent +import android.net.Uri import android.os.Bundle +import android.provider.OpenableColumns import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import ubi2.sonodrill.audio.AndroidAudioPlayer -import ubi2.sonodrill.audio.app_context +import ubi2.sonodrill.data.AUDIO_DIR import ubi2.sonodrill.data.AudioRepository import ubi2.sonodrill.data.DriverFactory +import ubi2.sonodrill.data.app_context +import ubi2.sonodrill.data.audio_dir +import ubi2.sonodrill.data.choose_import_name import ubi2.sonodrill.data.create_database -import ubi2.sonodrill.model.AudioFileInfo -import ubi2.sonodrill.model.current_time_ms +import ubi2.sonodrill.model.current_activity import ubi2.sonodrill.ui.SonodrillApp +import java.io.File +import java.lang.ref.WeakReference class MainActivity : ComponentActivity() { private lateinit var player: AndroidAudioPlayer private lateinit var repo: AudioRepository + private var on_import_done: (() -> Unit)? = null - 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())) + private val file_picker = registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris -> + val done = on_import_done + on_import_done = null + if (uris.isEmpty()) return@registerForActivityResult + lifecycleScope.launch { + withContext(Dispatchers.IO) { uris.forEach { import_audio(it) } } + done?.invoke() + } + } + + // Copies the picked document into the app's audio directory + private fun import_audio(uri: Uri) { + try { + var name = uri.lastPathSegment?.substringAfterLast('/') ?: "audio" + var size = -1L + contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), null, null, null)?.use { c -> + if (c.moveToFirst()) { + c.getString(0)?.let { name = it } + if (!c.isNull(1)) size = c.getLong(1) + } + } + val dir = audio_dir().apply { mkdirs() } + val (target, already_stored) = choose_import_name(name, size) { n -> File(dir, n).takeIf { it.exists() }?.length() } + if (!already_stored) { + val input = contentResolver.openInputStream(uri) ?: return + input.use { src -> File(dir, target).outputStream().use { src.copyTo(it) } } + } + repo.register_file("$AUDIO_DIR/$target") + } catch (e: Exception) { + println("Sonodrill: failed to import $uri: $e") } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) app_context = applicationContext + current_activity = WeakReference(this) 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/*")) }, + on_import = { on_done -> + on_import_done = on_done + file_picker.launch(arrayOf("audio/*")) + }, ) } } - override fun onDestroy() { super.onDestroy(); player.release() } + override fun onDestroy() { + super.onDestroy() + player.release() + } } diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts @@ -26,20 +26,17 @@ kotlin { 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) + implementation(libs.activity.compose) } iosMain.dependencies { implementation(libs.sqldelight.native) 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 @@ -1,7 +1,11 @@ package ubi2.sonodrill.audio import android.content.Context +import android.media.MediaCodec +import android.media.MediaExtractor +import android.media.MediaFormat import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata import androidx.media3.common.PlaybackParameters import androidx.media3.common.Player import androidx.media3.exoplayer.ExoPlayer @@ -9,6 +13,10 @@ import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import ubi2.sonodrill.data.resolve_audio_path +import java.io.File +import java.nio.ByteOrder +import kotlin.math.abs class AndroidAudioPlayer(private val context: Context) : AudioPlayer { private var exo: ExoPlayer? = null @@ -25,30 +33,123 @@ class AndroidAudioPlayer(private val context: Context) : AudioPlayer { private var position_job: Job? = null - override fun load(uri: String) { - release_player() + override fun load(uri: String, title: String) { + unload() + val item = MediaItem.Builder() + .setUri(android.net.Uri.fromFile(File(resolve_audio_path(uri)))) + .setMediaMetadata(MediaMetadata.Builder().setTitle(title).build()) + .build() 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 onPlaybackStateChanged(state: Int) { + when (state) { + Player.STATE_READY -> _duration.value = duration.coerceAtLeast(0) + Player.STATE_ENDED -> _position.value = _duration.value + } + } + override fun onIsPlayingChanged(playing: Boolean) { + _playing.value = playing + if (playing) start_position_updates() else { stop_position_updates(); _position.value = currentPosition } + } }) + setMediaItem(item) + playbackParameters = PlaybackParameters(_speed.value) + prepare() } } - override fun play() { exo?.play() } + override fun play() { + val player = exo ?: return + // At the end of the track, play restarts from the beginning + if (player.playbackState == Player.STATE_ENDED) seek(0) + player.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 seek(position_ms: Long) { + val target = if (_duration.value > 0) position_ms.coerceIn(0, _duration.value) else position_ms.coerceAtLeast(0) + exo?.seekTo(target) + _position.value = target + } + 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 } + override fun unload() { + stop_position_updates() + exo?.release(); exo = null + _playing.value = false + _position.value = 0 + _duration.value = 0 + } + + override fun release() { unload(); scope.cancel() } + 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) +// Peak amplitude of each of num_samples equal time slices of the track (first channel) +actual fun extract_waveform(uri: String, num_samples: Int): FloatArray { + val extractor = MediaExtractor() + var codec: MediaCodec? = null + try { + extractor.setDataSource(resolve_audio_path(uri)) + val track = (0 until extractor.trackCount).firstOrNull { + extractor.getTrackFormat(it).getString(MediaFormat.KEY_MIME)?.startsWith("audio/") == true + } ?: return FloatArray(0) + val format = extractor.getTrackFormat(track) + val duration_us = if (format.containsKey(MediaFormat.KEY_DURATION)) format.getLong(MediaFormat.KEY_DURATION) else 0L + if (duration_us <= 0 || num_samples <= 0) return FloatArray(0) + extractor.selectTrack(track) + codec = MediaCodec.createDecoderByType(format.getString(MediaFormat.KEY_MIME)!!) + codec.configure(format, null, null, 0) + codec.start() + + val peaks = FloatArray(num_samples) + var channels = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT) + var sample_rate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE) + val info = MediaCodec.BufferInfo() + var input_done = false + while (true) { + if (!input_done) { + val in_index = codec.dequeueInputBuffer(10_000) + if (in_index >= 0) { + val size = extractor.readSampleData(codec.getInputBuffer(in_index)!!, 0) + if (size < 0) { + codec.queueInputBuffer(in_index, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM) + input_done = true + } else { + codec.queueInputBuffer(in_index, 0, size, extractor.sampleTime, 0) + extractor.advance() + } + } + } + val out_index = codec.dequeueOutputBuffer(info, 10_000) + if (out_index == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { + channels = codec.outputFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT) + sample_rate = codec.outputFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE) + } else if (out_index >= 0) { + // Decoder output is 16-bit PCM, interleaved; the view starts at info.offset + val pcm = codec.getOutputBuffer(out_index)!!.order(ByteOrder.nativeOrder()).asShortBuffer() + val frames = (info.size / 2) / channels + for (f in 0 until frames) { + val t_us = info.presentationTimeUs + f * 1_000_000L / sample_rate + val bucket = (t_us * num_samples / duration_us).toInt().coerceIn(0, num_samples - 1) + val v = abs(pcm.get(f * channels).toInt()) / 32768f + if (v > peaks[bucket]) peaks[bucket] = v + } + codec.releaseOutputBuffer(out_index, false) + if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) break + } + } + return peaks + } catch (e: Exception) { + println("Sonodrill: waveform extraction failed: $e") + return FloatArray(0) + } finally { + codec?.runCatching { stop(); release() } + extractor.release() + } +} diff --git a/composeApp/src/androidMain/kotlin/ubi2/sonodrill/data/android_audio_storage.kt b/composeApp/src/androidMain/kotlin/ubi2/sonodrill/data/android_audio_storage.kt @@ -0,0 +1,19 @@ +package ubi2.sonodrill.data + +import android.content.Context +import java.io.File + +lateinit var app_context: Context + +fun audio_dir(): File = File(app_context.filesDir, AUDIO_DIR) + +actual fun resolve_audio_path(uri: String): String = File(app_context.filesDir, uri).path + +actual fun audio_file_exists(uri: String): Boolean = File(resolve_audio_path(uri)).exists() + +actual fun delete_audio_file(uri: String) { + File(resolve_audio_path(uri)).delete() +} + +actual fun list_stored_audio(): List<String> = + audio_dir().listFiles().orEmpty().filter { it.isFile && !it.name.startsWith(".") }.map { "$AUDIO_DIR/${it.name}" } diff --git a/composeApp/src/androidMain/kotlin/ubi2/sonodrill/model/android_platform.kt b/composeApp/src/androidMain/kotlin/ubi2/sonodrill/model/android_platform.kt @@ -1,11 +1,17 @@ package ubi2.sonodrill.model +import android.app.Activity +import android.view.WindowManager +import java.lang.ref.WeakReference + +// Set by MainActivity, for the few platform calls that need a window +var current_activity: WeakReference<Activity>? = null + 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() -actual fun open_url(url: String) {} -actual fun get_play_count(): Int = 0 -actual fun increment_play_count() {} -actual fun set_idle_timer_disabled(disabled: Boolean) {} -actual fun has_tipped(): Boolean = false -actual fun purchase_tip() {} + +actual fun set_idle_timer_disabled(disabled: Boolean) { + val window = current_activity?.get()?.window ?: return + if (disabled) window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + else window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) +} diff --git a/composeApp/src/androidMain/kotlin/ubi2/sonodrill/sync/android_sync.kt b/composeApp/src/androidMain/kotlin/ubi2/sonodrill/sync/android_sync.kt @@ -1,14 +0,0 @@ -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/androidMain/kotlin/ubi2/sonodrill/ui/android_platform_ui.kt b/composeApp/src/androidMain/kotlin/ubi2/sonodrill/ui/android_platform_ui.kt @@ -0,0 +1,7 @@ +package ubi2.sonodrill.ui + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable + +@Composable +actual fun PlatformBackHandler(on_back: () -> Unit) = BackHandler(onBack = on_back) diff --git a/composeApp/src/commonMain/kotlin/ubi2/sonodrill/audio/audio_player.kt b/composeApp/src/commonMain/kotlin/ubi2/sonodrill/audio/audio_player.kt @@ -2,21 +2,24 @@ package ubi2.sonodrill.audio import kotlinx.coroutines.flow.StateFlow +// Platform players are driven from the main thread only. 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) + // uri is relative to the app's storage (see audio_storage.kt); title is shown on the lock screen + fun load(uri: String, title: String) fun play() fun pause() fun toggle() fun seek(position_ms: Long) fun set_speed(rate: Float) - fun skip(delta_ms: Long) + // Stops and forgets the current file; the player can load another one afterwards + fun unload() + // Final teardown, the player can't be used anymore 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 @@ -12,7 +12,7 @@ class LoopController(private val player: AudioPlayer) { val state: StateFlow<LoopState> = _state.asStateFlow() private var loop_job: Job? = null - private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) fun set_loop(a: MarkInfo, b: MarkInfo, seek_to_start: Boolean = true) { val (start, end) = if (a.position_ms <= b.position_ms) a to b else b to a @@ -21,13 +21,6 @@ class LoopController(private val player: AudioPlayer) { 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() @@ -40,7 +33,12 @@ class LoopController(private val player: AudioPlayer) { 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) + if (pos >= end) { + // Reaching the end of the track stops the player: restart it for loops ending there + val track_ended = !player.is_playing.value && player.duration_ms.value in 1..pos + player.seek(start) + if (track_ended) player.play() + } delay(50) } } diff --git a/composeApp/src/commonMain/kotlin/ubi2/sonodrill/data/audio_storage.kt b/composeApp/src/commonMain/kotlin/ubi2/sonodrill/data/audio_storage.kt @@ -0,0 +1,29 @@ +package ubi2.sonodrill.data + +// Imported audio is copied into the app's own storage, under AUDIO_DIR. The +// database only keeps paths relative to the storage directory: on iOS the +// absolute container path changes with every app update. +const val AUDIO_DIR = "audio" + +expect fun resolve_audio_path(uri: String): String +expect fun audio_file_exists(uri: String): Boolean +expect fun delete_audio_file(uri: String) +expect fun list_stored_audio(): List<String> + +fun title_from_uri(uri: String): String = uri.substringAfterLast('/').substringBeforeLast('.') + +// Picks the name under which an imported file is stored. A stored file with the +// same name and size is taken to be the same file (second value = true); a +// different file with the same name gets a numbered name ("song-2.mp3"). +fun choose_import_name(name: String, size: Long, stored_size: (String) -> Long?): Pair<String, Boolean> { + val base = name.substringBeforeLast('.') + val ext = if ('.' in name) "." + name.substringAfterLast('.') else "" + var candidate = name + var n = 2 + while (true) { + val existing = stored_size(candidate) ?: return candidate to false + if (size >= 0 && existing == size) return candidate to true + candidate = "$base-$n$ext" + n++ + } +} diff --git a/composeApp/src/commonMain/kotlin/ubi2/sonodrill/data/repository.kt b/composeApp/src/commonMain/kotlin/ubi2/sonodrill/data/repository.kt @@ -1,65 +1,96 @@ package ubi2.sonodrill.data import ubi2.sonodrill.model.AudioFileInfo +import ubi2.sonodrill.model.LoopZone import ubi2.sonodrill.model.MarkInfo +import ubi2.sonodrill.model.SkipZone import ubi2.sonodrill.model.current_time_ms -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.map +import ubi2.sonodrill.model.generate_uuid + +private const val ZONE_SKIP = "skip" +private const val ZONE_LOOP = "loop" class AudioRepository(private val db: SonodrillDatabase) { private val queries get() = db.audioFileQueries private val mark_queries get() = db.markQueries + private val zone_queries get() = db.zoneQueries - 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> = + fun get_all_files(): 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, - ) + // Registers a stored audio file; importing the same file again keeps its entry (and marks). + fun register_file(uri: String) { + val existing = queries.select_by_uri(uri).executeAsOneOrNull() + if (existing != null) { + queries.touch(current_time_ms(), existing.id) + } else { + queries.insert( + id = generate_uuid(), uri = uri, title = title_from_uri(uri), duration_ms = null, + last_position_ms = 0, last_speed = 1.0, updated_at = current_time_ms(), + ) + } + } + + fun rename_file(id: String, title: String) = queries.update_title(title, current_time_ms(), id) + + fun update_duration(id: String, duration_ms: Long) = queries.update_duration(duration_ms, id) 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) + // Removes the entry with its marks and zones, and the stored audio unless another entry uses it. + fun delete_file(file: AudioFileInfo) { + db.transaction { + mark_queries.delete_by_file(file.id) + zone_queries.delete_by_file(file.id) + queries.delete_by_id(file.id) + } + if (queries.count_by_uri(file.uri).executeAsOne() == 0L) delete_audio_file(file.uri) + } + + // Library listing: adds stored files that have no entry and flags entries whose file is gone. + fun sync_library(): List<AudioFileInfo> { + val known = get_all_files().map { it.uri }.toSet() + list_stored_audio().filter { it !in known }.forEach { register_file(it) } + return get_all_files().map { it.copy(missing = !audio_file_exists(it.uri)) } + } fun get_marks(file_id: String): List<MarkInfo> = - mark_queries.select_by_file(file_id).executeAsList().map { it.to_mark_info() } + mark_queries.select_by_file(file_id).executeAsList().map { + MarkInfo(id = it.id, file_id = it.file_id, position_ms = it.position_ms, label = it.label, created_at = it.created_at) + } + + fun insert_mark(mark: MarkInfo) = + mark_queries.insert(mark.id, mark.file_id, mark.position_ms, mark.label, mark.created_at) - 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 rename_mark(id: String, label: String?) = mark_queries.update_label(label, id) fun delete_mark(id: String) = mark_queries.delete_by_id(id) + + fun get_skip_zones(file_id: String): List<SkipZone> = + zone_queries.select_by_file(file_id).executeAsList().filter { it.kind == ZONE_SKIP }.map { SkipZone(it.start_ms, it.end_ms) } + + fun get_loop_zones(file_id: String): List<LoopZone> = + zone_queries.select_by_file(file_id).executeAsList().filter { it.kind == ZONE_LOOP }.map { LoopZone(it.start_ms, it.end_ms) } + + fun save_skip_zones(file_id: String, zones: List<SkipZone>) = + save_zones(file_id, ZONE_SKIP, zones.map { it.start_ms to it.end_ms }) + + fun save_loop_zones(file_id: String, zones: List<LoopZone>) = + save_zones(file_id, ZONE_LOOP, zones.map { it.start_ms to it.end_ms }) + + private fun save_zones(file_id: String, kind: String, zones: List<Pair<Long, Long>>) { + db.transaction { + zone_queries.delete_by_file_kind(file_id, kind) + zones.forEach { (start, end) -> zone_queries.insert(file_id, kind, start, end) } + } + } } 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 @@ -2,12 +2,15 @@ package ubi2.sonodrill.model data class AudioFileInfo( val id: String, + // Relative to the app's storage directory (e.g. "audio/song.mp3"), see audio_storage.kt 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, + // Not stored: set when the audio file can't be found on disk + val missing: Boolean = false, ) data class MarkInfo( @@ -15,7 +18,6 @@ data class MarkInfo( val file_id: String, val position_ms: Long, val label: String? = null, - val color: String = "#FF6B35", val created_at: Long = 0, ) diff --git a/composeApp/src/commonMain/kotlin/ubi2/sonodrill/model/platform.kt b/composeApp/src/commonMain/kotlin/ubi2/sonodrill/model/platform.kt @@ -2,10 +2,4 @@ package ubi2.sonodrill.model expect fun current_time_ms(): Long expect fun generate_uuid(): String -expect fun file_exists(path: String): Boolean -expect fun open_url(url: String) -expect fun get_play_count(): Int -expect fun increment_play_count() -expect fun has_tipped(): Boolean -expect fun purchase_tip() expect fun set_idle_timer_disabled(disabled: 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 @@ -1,14 +0,0 @@ -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 @@ -1,5 +1,6 @@ package ubi2.sonodrill.ui +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme @@ -8,7 +9,6 @@ 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 @@ -23,22 +23,15 @@ private val dark_scheme = darkColorScheme( enum class Screen { LIBRARY, PLAYER } @Composable -fun SonodrillApp(player: AudioPlayer, repo: AudioRepository, on_import: (on_done: () -> Unit) -> Unit, is_dark: Boolean = false) { +fun SonodrillApp(player: AudioPlayer, repo: AudioRepository, on_import: (on_done: () -> Unit) -> Unit, is_dark: Boolean = isSystemInDarkTheme()) { 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 - } + fun refresh_files() { files = repo.sync_library() } - if (!initialized) { refresh_files(); initialized = true } + LaunchedEffect(Unit) { refresh_files() } MaterialTheme(colorScheme = scheme) { when (screen) { @@ -48,7 +41,7 @@ fun SonodrillApp(player: AudioPlayer, repo: AudioRepository, on_import: (on_done 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() }, + on_delete = { file -> repo.delete_file(file); 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 @@ -16,8 +16,6 @@ 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.model.has_tipped -import ubi2.sonodrill.model.purchase_tip import ubi2.sonodrill.ui.player.format_time @OptIn(ExperimentalMaterial3Api::class) @@ -26,30 +24,15 @@ fun LibraryScreen( files: List<AudioFileInfo>, on_file_click: (AudioFileInfo) -> Unit, on_import: () -> Unit, - on_delete: (String) -> Unit, + on_delete: (AudioFileInfo) -> Unit, ) { var search by remember { mutableStateOf("") } val filtered = if (search.isBlank()) files else files.filter { it.title.contains(search, ignoreCase = true) } - var show_tip_dialog by remember { mutableStateOf(false) } - var tipped by remember { mutableStateOf(has_tipped()) } + var to_delete by remember { mutableStateOf<AudioFileInfo?>(null) } Scaffold( topBar = { - TopAppBar( - title = { Text("Sonodrill") }, - actions = { - IconButton(onClick = { - tipped = has_tipped() - show_tip_dialog = true - }) { - Icon( - if (tipped) Icons.Default.Favorite else Icons.Default.FavoriteBorder, - "Support", - tint = MaterialTheme.colorScheme.error, - ) - } - }, - ) + TopAppBar(title = { Text("Sonodrill") }) }, floatingActionButton = { FloatingActionButton(onClick = on_import) { Icon(Icons.Default.Add, "Import audio") } }, ) { padding -> @@ -70,49 +53,23 @@ fun LibraryScreen( } 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) }) + AudioFileItem(file, on_click = { on_file_click(file) }, on_delete = { to_delete = file }) } } } } } - if (show_tip_dialog) { + to_delete?.let { file -> AlertDialog( - onDismissRequest = { show_tip_dialog = false }, - icon = { - Icon( - if (tipped) Icons.Default.Favorite else Icons.Default.FavoriteBorder, - null, - tint = MaterialTheme.colorScheme.error, - modifier = Modifier.size(48.dp), - ) - }, - title = { - Text(if (tipped) "Thank you!" else "Support Sonodrill") - }, - text = { - Text( - if (tipped) - "Thanks for your generous support! Your contribution helps keep Sonodrill alive and improving." - else - "Sonodrill is made with love by an independent developer. If you enjoy using it, consider leaving a friendly tip to support its development." - ) - }, + onDismissRequest = { to_delete = null }, + title = { Text("Delete \"${file.title}\"?") }, + text = { Text("The audio file, its marks and its zones will be removed.") }, confirmButton = { - if (tipped) { - TextButton(onClick = { show_tip_dialog = false }) { Text("Close") } - } else { - Button(onClick = { - purchase_tip() - show_tip_dialog = false - }) { Text("Tip \$5") } - } + TextButton(onClick = { on_delete(file); to_delete = null }) { Text("Delete", color = MaterialTheme.colorScheme.error) } }, dismissButton = { - if (!tipped) { - TextButton(onClick = { show_tip_dialog = false }) { Text("Maybe later") } - } + TextButton(onClick = { to_delete = null }) { Text("Cancel") } }, ) } @@ -120,13 +77,17 @@ fun LibraryScreen( @Composable private fun AudioFileItem(file: AudioFileInfo, on_click: () -> Unit, on_delete: () -> Unit) { - Card(modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)).clickable(onClick = on_click)) { + Card(modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)).clickable(enabled = !file.missing, 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) + Icon( + if (file.missing) Icons.Default.ErrorOutline else Icons.Default.AudioFile, null, modifier = Modifier.size(40.dp), + tint = if (file.missing) MaterialTheme.colorScheme.error else 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.missing) Text("File not found", fontSize = 13.sp, color = MaterialTheme.colorScheme.error) + else 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)) diff --git a/composeApp/src/commonMain/kotlin/ubi2/sonodrill/ui/platform_ui.kt b/composeApp/src/commonMain/kotlin/ubi2/sonodrill/ui/platform_ui.kt @@ -0,0 +1,7 @@ +package ubi2.sonodrill.ui + +import androidx.compose.runtime.Composable + +// System back navigation (Android back button/gesture); no-op on iOS +@Composable +expect fun PlatformBackHandler(on_back: () -> Unit) 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 @@ -4,7 +4,6 @@ import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTransformGestures import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.BoxWithConstraints @@ -12,13 +11,14 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward 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 @@ -31,6 +31,7 @@ import ubi2.sonodrill.model.LoopZone import ubi2.sonodrill.model.MarkInfo import ubi2.sonodrill.model.SkipZone import ubi2.sonodrill.model.set_idle_timer_disabled +import ubi2.sonodrill.ui.PlatformBackHandler @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -50,6 +51,7 @@ fun PlayerScreen(vm: PlayerViewModel, on_back: () -> Unit) { val play_delay_enabled by vm.play_delay_enabled.collectAsState() val countdown by vm.countdown.collectAsState() + PlatformBackHandler(on_back) LaunchedEffect(playing) { set_idle_timer_disabled(playing) } DisposableEffect(Unit) { onDispose { set_idle_timer_disabled(false) } } @@ -68,7 +70,7 @@ fun PlayerScreen(vm: PlayerViewModel, on_back: () -> Unit) { ) }, navigationIcon = { - IconButton(onClick = on_back) { Icon(Icons.Default.ArrowBack, "Back") } + IconButton(onClick = on_back) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } }, ) }, @@ -86,33 +88,7 @@ fun PlayerScreen(vm: PlayerViewModel, on_back: () -> Unit) { skip_zones = skip_zones, loop_zones = loop_zones, loop_state = loop_state, - on_seek = { tap_ms -> - // Check if tapping inside a loop zone - val tapped_zone = loop_zones.firstOrNull { tap_ms >= it.start_ms && tap_ms < it.end_ms } - if (tapped_zone != null) { - val ls = loop_state - val already_active = ls.enabled && ls.mark_a?.position_ms == tapped_zone.start_ms && ls.mark_b?.position_ms == tapped_zone.end_ms - vm.tap_in_loop_zone(tap_ms) - // If changing zone, seek to start; if same zone, seek to tap position - vm.seek(if (already_active) tap_ms else tapped_zone.start_ms) - return@ProgressTimeline - } - // If mark loop is active and tap is outside current loop - 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) - val new_ls = vm.loop_state.value - vm.seek(new_ls.mark_a?.position_ms ?: tap_ms) - } else { - vm.seek(tap_ms) - } - } else { - vm.seek(tap_ms) - } - }, + on_seek = { tap_ms -> vm.tap_timeline(tap_ms) }, on_mark_label_click = { mark -> mark_to_rename = mark }, ) } @@ -184,8 +160,8 @@ fun PlayerScreen(vm: PlayerViewModel, on_back: () -> Unit) { 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" + val mark_loop_active = loop_state.is_mark_loop() + val full_loop_active = loop_state.is_full_loop() // Full loop toggle if (full_loop_active) { @@ -232,14 +208,14 @@ fun PlayerScreen(vm: PlayerViewModel, on_back: () -> Unit) { } 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.AutoMirrored.Filled.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)) + Icon(Icons.AutoMirrored.Filled.ArrowForward, null, modifier = Modifier.size(14.dp)) } } // Add/remove skip zone @@ -350,6 +326,21 @@ fun PlayerScreen(vm: PlayerViewModel, on_back: () -> Unit) { private const val BASE_ROWS = 6 private const val ROW_HEIGHT_DP = 56 +private const val MAX_BARS_PER_ROW = 160 + +// Peak of each bucket, so a row never draws more bars than it can show +private fun downsample(waveform: FloatArray, from: Int, to: Int, max_bars: Int): FloatArray { + val count = to - from + if (count <= 0) return FloatArray(0) + if (count <= max_bars) return waveform.copyOfRange(from, to) + return FloatArray(max_bars) { bar -> + val start = from + (bar.toLong() * count / max_bars).toInt() + val end = (from + ((bar + 1).toLong() * count / max_bars).toInt()).coerceAtLeast(start + 1) + var peak = 0f + for (i in start until end) if (waveform[i] > peak) peak = waveform[i] + peak + } +} @Composable private fun ProgressTimeline( @@ -471,12 +462,13 @@ private fun TimelineRow( 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) + val bars = remember(waveform, wave_start, wave_end) { downsample(waveform, wave_start, wave_end, MAX_BARS_PER_ROW) } Box( modifier = modifier .clip(RoundedCornerShape(6.dp)) .background(surface_variant) - .pointerInput(row, duration_ms) { + .pointerInput(row, row_duration) { 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() @@ -532,11 +524,11 @@ private fun TimelineRow( } // Waveform or simple progress - val row_samples = wave_end - wave_start + val row_samples = bars.size 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 amp = bars[i].coerceIn(0f, 1f) val bar_h = amp * h * 0.9f val x = i * bar_width val frac = (i + 1).toFloat() / row_samples 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 @@ -11,10 +11,17 @@ import ubi2.sonodrill.model.MarkInfo import ubi2.sonodrill.model.SkipZone import ubi2.sonodrill.model.current_time_ms import ubi2.sonodrill.model.generate_uuid -import ubi2.sonodrill.model.increment_play_count import kotlinx.coroutines.* import kotlinx.coroutines.flow.* +// Ids of the synthetic marks bounding a whole-track loop +const val FULL_LOOP_A = "_full_a" +const val FULL_LOOP_B = "_full_b" + +fun LoopState.is_mark_loop() = enabled && mark_a != null && mark_a.id != FULL_LOOP_A +fun LoopState.is_full_loop() = enabled && mark_a?.id == FULL_LOOP_A +fun LoopState.is_zone(zone: LoopZone) = enabled && mark_a?.position_ms == zone.start_ms && mark_b?.position_ms == zone.end_ms + class PlayerViewModel( private val player: AudioPlayer, private val repo: AudioRepository, @@ -46,48 +53,54 @@ class PlayerViewModel( private val _play_delay_enabled = MutableStateFlow(false) val play_delay_enabled: StateFlow<Boolean> = _play_delay_enabled.asStateFlow() + private val _countdown = MutableStateFlow(0) + val countdown: StateFlow<Int> = _countdown.asStateFlow() + fun set_play_delay(seconds: Int) { _play_delay_s.value = seconds } fun toggle_play_delay() { _play_delay_enabled.value = !_play_delay_enabled.value } - private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + // Main thread: the platform players must not be driven from elsewhere + private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) private var save_job: Job? = null private var skip_job: Job? = null + private var delay_job: Job? = null fun load_file(file: AudioFileInfo) { _current_file.value = file - player.load(file.uri) - increment_play_count() + player.load(file.uri, file.title) _marks.value = repo.get_marks(file.id) + _skip_zones.value = repo.get_skip_zones(file.id) + _loop_zones.value = repo.get_loop_zones(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 { val dur = duration.first { it > 0 } - _waveform.value = extract_waveform(file.uri, (dur / 20).toInt().coerceAtLeast(1)) + if (file.duration_ms != dur) repo.update_duration(file.id, dur) + _waveform.value = withContext(Dispatchers.Default) { + extract_waveform(file.uri, (dur / 20).toInt().coerceAtLeast(1)) + } } start_auto_save() start_skip_monitoring() } - private var delay_job: Job? = null - private val _countdown = MutableStateFlow(0) - val countdown: StateFlow<Int> = _countdown.asStateFlow() - fun rename_file(new_title: String) { val file = _current_file.value ?: return - val updated = file.copy(title = new_title, updated_at = current_time_ms()) - repo.upsert_file(updated) - _current_file.value = updated + val title = new_title.trim().ifEmpty { return } + repo.rename_file(file.id, title) + _current_file.value = file.copy(title = title) } fun toggle_playback() { + if (delay_job?.isActive == true) { + cancel_countdown() + return + } if (is_playing.value) { - delay_job?.cancel() - _countdown.value = 0 player.pause() return } if (_play_delay_enabled.value && _play_delay_s.value > 0) { - delay_job?.cancel() delay_job = scope.launch { for (i in _play_delay_s.value downTo 1) { _countdown.value = i @@ -100,56 +113,80 @@ class PlayerViewModel( player.play() } } - fun seek(ms: Long) = player.seek(ms) + + private fun cancel_countdown() { + delay_job?.cancel() + delay_job = null + _countdown.value = 0 + } + 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) + // A tap on the timeline: inside a loop zone it activates that zone (or moves + // within it when already active); outside an active mark loop it moves the + // loop around the tapped position. + fun tap_timeline(tap_ms: Long) { + val ls = loop_state.value + val zone = _loop_zones.value.firstOrNull { tap_ms >= it.start_ms && tap_ms < it.end_ms } + if (zone != null) { + if (ls.is_zone(zone)) { + player.seek(tap_ms) + } else { + activate_loop_zone(zone) + player.seek(zone.start_ms) + } + return + } + if (ls.is_mark_loop()) { + val a = ls.mark_a!!.position_ms + val b = ls.mark_b?.position_ms ?: a + if (tap_ms < a || tap_ms > b) { + loop_around(tap_ms) + player.seek(loop_state.value.mark_a?.position_ms ?: tap_ms) + return + } + } + player.seek(tap_ms) + } + + fun add_mark() { + add_mark_at(position.value) + } + + private fun add_mark_at(pos: Long): MarkInfo? { + val file = _current_file.value ?: return null + val mark = MarkInfo(id = generate_uuid(), file_id = file.id, position_ms = pos, created_at = current_time_ms()) + repo.insert_mark(mark) _marks.value = repo.get_marks(file.id) + return mark } fun rename_mark(id: String, label: String?) { val file = _current_file.value ?: return - val mark = _marks.value.firstOrNull { it.id == id } ?: return - repo.upsert_mark(mark.copy(label = label)) + repo.rename_mark(id, label) _marks.value = repo.get_marks(file.id) } - fun delete_mark(id: String) { + private fun delete_mark(mark: MarkInfo) { val file = _current_file.value ?: return - repo.delete_mark(id) + repo.delete_mark(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() - cleanup_skip_zones() + if (loop_state.value.mark_a?.id == mark.id || loop_state.value.mark_b?.id == mark.id) loop.clear() + cleanup_zones() } - 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!! + if (ls.is_mark_loop() && 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 } } @@ -157,75 +194,44 @@ class PlayerViewModel( loop.clear() return } - _marks.value.firstOrNull { it.position_ms > pos + 500 }?.let { seek_to_mark(it) } + _marks.value.firstOrNull { it.position_ms > pos + 500 }?.let { player.seek(it.position_ms) } } fun prev_mark() { val pos = position.value val prev = _marks.value.lastOrNull { it.position_ms < pos - 500 } - if (prev != null) seek_to_mark(prev) else player.seek(0) + player.seek(prev?.position_ms ?: 0) } 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() - cleanup_skip_zones() + _marks.value.lastOrNull { it.position_ms <= pos }?.let { delete_mark(it) } } 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() - cleanup_skip_zones() + _marks.value.firstOrNull { it.position_ms > pos }?.let { delete_mark(it) } } - fun loop_around(position_ms: Long) { - // Check if tapping inside an existing loop zone - val zone = _loop_zones.value.firstOrNull { position_ms >= it.start_ms && position_ms < it.end_ms } - if (zone != null) { - activate_loop_zone(zone) - return - } + private 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) - } + if (prev != null && next != null) loop.set_loop(prev, next) } fun add_mark_and_loop() { - val file = _current_file.value ?: return val pos = position.value - val new_mark = MarkInfo( - id = generate_uuid(), - file_id = file.id, - position_ms = pos, - created_at = current_time_ms(), - ) - repo.upsert_mark(new_mark) - _marks.value = repo.get_marks(file.id) - val sorted = _marks.value.sortedBy { it.position_ms } - val prev = sorted.lastOrNull { it.position_ms < pos } - if (prev != null) { - val zone = LoopZone(prev.position_ms, new_mark.position_ms) - if (_loop_zones.value.none { it.start_ms == zone.start_ms && it.end_ms == zone.end_ms }) { - _loop_zones.value = (_loop_zones.value + zone).sortedBy { it.start_ms } - } - activate_loop_zone(zone) - } + val new_mark = add_mark_at(pos) ?: return + val prev = _marks.value.lastOrNull { it.position_ms < pos } ?: return + val zone = LoopZone(prev.position_ms, new_mark.position_ms) + add_loop_zone(zone) + activate_loop_zone(zone) } fun toggle_mark_loop() { // If loop is active, deactivate - if (loop_state.value.enabled && loop_state.value.mark_a?.id != "_full_a") { + if (loop_state.value.enabled && !loop_state.value.is_full_loop()) { loop.clear() return } @@ -236,42 +242,21 @@ class PlayerViewModel( val next = sorted.firstOrNull { it.position_ms > pos - 500 && it != prev } if (prev != null && next != null) { val zone = LoopZone(prev.position_ms, next.position_ms) - if (_loop_zones.value.none { it.start_ms == zone.start_ms && it.end_ms == zone.end_ms }) { - _loop_zones.value = (_loop_zones.value + zone).sortedBy { it.start_ms } - } + add_loop_zone(zone) activate_loop_zone(zone) } } - fun activate_loop_zone(zone: LoopZone) { - val sorted = _marks.value.sortedBy { it.position_ms } - val a = sorted.lastOrNull { it.position_ms <= zone.start_ms } ?: MarkInfo(id = "_lz_a", file_id = "", position_ms = zone.start_ms, created_at = 0) - val b = sorted.firstOrNull { it.position_ms >= zone.end_ms } ?: MarkInfo(id = "_lz_b", file_id = "", position_ms = zone.end_ms, created_at = 0) - loop.set_loop(a, b, seek_to_start = false) + private fun add_loop_zone(zone: LoopZone) { + if (_loop_zones.value.any { it == zone }) return + set_loop_zones((_loop_zones.value + zone).sortedBy { it.start_ms }) } - fun remove_loop_zone(zone: LoopZone) { - _loop_zones.value = _loop_zones.value.filter { it != zone } - // If this was the active loop, clear it - val ls = loop_state.value - if (ls.enabled && ls.mark_a != null && ls.mark_b != null) { - if (ls.mark_a!!.position_ms == zone.start_ms && ls.mark_b!!.position_ms == zone.end_ms) { - loop.clear() - } - } - } - - fun tap_in_loop_zone(position_ms: Long) { - val zone = _loop_zones.value.firstOrNull { position_ms >= it.start_ms && position_ms < it.end_ms } - if (zone != null) { - val ls = loop_state.value - // If this zone is already active, deactivate - if (ls.enabled && ls.mark_a?.position_ms == zone.start_ms && ls.mark_b?.position_ms == zone.end_ms) { - loop.clear() - } else { - activate_loop_zone(zone) - } - } + private fun activate_loop_zone(zone: LoopZone) { + val sorted = _marks.value.sortedBy { it.position_ms } + val a = sorted.lastOrNull { it.position_ms <= zone.start_ms } ?: MarkInfo(id = "_lz_a", file_id = "", position_ms = zone.start_ms) + val b = sorted.firstOrNull { it.position_ms >= zone.end_ms } ?: MarkInfo(id = "_lz_b", file_id = "", position_ms = zone.end_ms) + loop.set_loop(a, b, seek_to_start = false) } fun toggle_full_loop() { @@ -280,39 +265,43 @@ class PlayerViewModel( 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) + val a = MarkInfo(id = FULL_LOOP_A, file_id = "", position_ms = 0) + val b = MarkInfo(id = FULL_LOOP_B, file_id = "", position_ms = dur) loop.set_loop(a, b) } } - private fun cleanup_skip_zones() { + // Zones are bounded by marks: drop those that lost a bound + private fun cleanup_zones() { val mark_positions = _marks.value.map { it.position_ms }.toSet() - _skip_zones.value = _skip_zones.value.filter { zone -> - mark_positions.contains(zone.start_ms) && mark_positions.contains(zone.end_ms) - } - _loop_zones.value = _loop_zones.value.filter { zone -> - mark_positions.contains(zone.start_ms) && mark_positions.contains(zone.end_ms) - } + set_skip_zones(_skip_zones.value.filter { mark_positions.contains(it.start_ms) && mark_positions.contains(it.end_ms) }) + set_loop_zones(_loop_zones.value.filter { mark_positions.contains(it.start_ms) && mark_positions.contains(it.end_ms) }) + } + + private fun set_skip_zones(zones: List<SkipZone>) { + if (zones == _skip_zones.value) return + _skip_zones.value = zones + _current_file.value?.let { repo.save_skip_zones(it.id, zones) } + } + + private fun set_loop_zones(zones: List<LoopZone>) { + if (zones == _loop_zones.value) return + _loop_zones.value = zones + _current_file.value?.let { repo.save_loop_zones(it.id, zones) } } fun add_skip_zone() { val pos = position.value val sorted = _marks.value.sortedBy { it.position_ms } - val prev = sorted.lastOrNull { it.position_ms <= pos } - val next = sorted.firstOrNull { it.position_ms > pos } - if (prev != null && next != null) { - val zone = SkipZone(prev.position_ms, next.position_ms) - // Don't add duplicate - if (_skip_zones.value.none { it.start_ms == zone.start_ms && it.end_ms == zone.end_ms }) { - _skip_zones.value = (_skip_zones.value + zone).sortedBy { it.start_ms } - } - } + val prev = sorted.lastOrNull { it.position_ms <= pos } ?: return + val next = sorted.firstOrNull { it.position_ms > pos } ?: return + val zone = SkipZone(prev.position_ms, next.position_ms) + if (_skip_zones.value.none { it == zone }) set_skip_zones((_skip_zones.value + zone).sortedBy { it.start_ms }) } fun remove_skip_zone_at_position() { val pos = position.value - _skip_zones.value = _skip_zones.value.filter { !(pos >= it.start_ms && pos < it.end_ms) } + set_skip_zones(_skip_zones.value.filter { !(pos >= it.start_ms && pos < it.end_ms) }) } private fun start_skip_monitoring() { @@ -323,9 +312,7 @@ class PlayerViewModel( if (!is_playing.value) continue val pos = position.value val zone = _skip_zones.value.firstOrNull { pos >= it.start_ms && pos < it.end_ms } - if (zone != null) { - player.seek(zone.end_ms) - } + if (zone != null) player.seek(zone.end_ms) } } } @@ -344,10 +331,8 @@ class PlayerViewModel( fun release() { val file = _current_file.value if (file != null) repo.update_position(file.id, position.value, speed.value) - save_job?.cancel() - skip_job?.cancel() - loop.release() - player.release() scope.cancel() + loop.release() + player.unload() } } diff --git a/composeApp/src/commonMain/sqldelight/ubi2/sonodrill/data/1.sqm b/composeApp/src/commonMain/sqldelight/ubi2/sonodrill/data/1.sqm @@ -0,0 +1,13 @@ +-- v1 -> v2: persisted skip/loop zones, and audio paths relative to the app's +-- documents directory (iOS changes the container path on every app update). +CREATE TABLE zone ( + file_id TEXT NOT NULL, + kind TEXT NOT NULL, + start_ms INTEGER NOT NULL, + end_ms INTEGER NOT NULL, + PRIMARY KEY (file_id, kind, start_ms, end_ms) +); + +UPDATE audio_file +SET uri = substr(uri, instr(uri, '/Documents/') + length('/Documents/')) +WHERE instr(uri, '/Documents/') > 0; diff --git a/composeApp/src/commonMain/sqldelight/ubi2/sonodrill/data/AudioFile.sq b/composeApp/src/commonMain/sqldelight/ubi2/sonodrill/data/AudioFile.sq @@ -14,12 +14,27 @@ 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) +select_by_uri: +SELECT * FROM audio_file WHERE uri = ? LIMIT 1; + +count_by_uri: +SELECT COUNT(*) FROM audio_file WHERE uri = ?; + +insert: +INSERT INTO audio_file(id, uri, title, duration_ms, last_position_ms, last_speed, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?); +update_title: +UPDATE audio_file SET title = ?, updated_at = ? WHERE id = ?; + +update_duration: +UPDATE audio_file SET duration_ms = ? WHERE id = ?; + update_position: UPDATE audio_file SET last_position_ms = ?, last_speed = ?, updated_at = ? WHERE id = ?; +touch: +UPDATE audio_file SET 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 @@ -9,11 +9,14 @@ CREATE TABLE mark ( ); select_by_file: -SELECT * FROM mark WHERE file_id = ? ORDER BY position_ms ASC; +SELECT id, file_id, position_ms, label, created_at 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 (?, ?, ?, ?, ?, ?); +insert: +INSERT INTO mark(id, file_id, position_ms, label, created_at) +VALUES (?, ?, ?, ?, ?); + +update_label: +UPDATE mark SET label = ? WHERE id = ?; delete_by_id: DELETE FROM mark WHERE id = ?; diff --git a/composeApp/src/commonMain/sqldelight/ubi2/sonodrill/data/Zone.sq b/composeApp/src/commonMain/sqldelight/ubi2/sonodrill/data/Zone.sq @@ -0,0 +1,20 @@ +-- Skip zones and loop zones of a file (kind = 'skip' | 'loop'). +CREATE TABLE zone ( + file_id TEXT NOT NULL, + kind TEXT NOT NULL, + start_ms INTEGER NOT NULL, + end_ms INTEGER NOT NULL, + PRIMARY KEY (file_id, kind, start_ms, end_ms) +); + +select_by_file: +SELECT * FROM zone WHERE file_id = ? ORDER BY start_ms ASC; + +insert: +INSERT OR IGNORE INTO zone(file_id, kind, start_ms, end_ms) VALUES (?, ?, ?, ?); + +delete_by_file_kind: +DELETE FROM zone WHERE file_id = ? AND kind = ?; + +delete_by_file: +DELETE FROM zone 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 @@ -1,4 +1,4 @@ -@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class, kotlinx.cinterop.BetaInteropApi::class) package ubi2.sonodrill.audio import kotlinx.cinterop.* @@ -8,13 +8,20 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import platform.AVFAudio.* import platform.Foundation.NSError +import platform.Foundation.NSNotification +import platform.Foundation.NSNotificationCenter +import platform.Foundation.NSNumber +import platform.Foundation.NSOperationQueue import platform.Foundation.NSURL +import platform.MediaPlayer.* +import ubi2.sonodrill.data.resolve_audio_path 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 var title: String = "" private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) private val _position = MutableStateFlow(0L) @@ -27,35 +34,39 @@ class IosAudioPlayer : AudioPlayer { override val playback_speed: StateFlow<Float> = _speed.asStateFlow() private var position_job: Job? = null - private var seek_offset_ms: Long = 0 + // Position is extrapolated from a monotonic clock (node render time is unreliable over Bluetooth) private var play_start_mark = kotlin.time.TimeSource.Monotonic.markNow() private var play_start_position_ms: Long = 0 + // Playing when an interruption (call, Siri, alarm) began, to resume after it + private var interrupted_while_playing = false + private val observers = mutableListOf<Any>() - 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 - } + init { + configure_session() + observe_notifications() + setup_remote_commands() + } + + override fun load(uri: String, title: String) { + unload() + this.title = title + val path = resolve_audio_path(uri) + val file: AVAudioFile = memScoped { + val err = alloc<ObjCObjectVar<NSError?>>() + val opened = AVAudioFile(forReading = NSURL.fileURLWithPath(path), error = err.ptr) + if (err.value != null) { + println("Sonodrill: failed to open audio file $path: ${err.value?.localizedDescription}") + null + } else opened + } ?: return val format = file.processingFormat if (format.sampleRate == 0.0) { - println("Sonodrill: invalid audio format for: $uri") + println("Sonodrill: invalid audio format for: $path") return } audio_file = file _duration.value = ((file.length.toDouble() / format.sampleRate) * 1000).toLong() + _position.value = 0 val eng = AVAudioEngine() val node = AVAudioPlayerNode() @@ -64,82 +75,211 @@ class IosAudioPlayer : AudioPlayer { 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 + eng.prepare() + start_engine() node.scheduleFile(file, atTime = null, completionHandler = null) + update_now_playing() } override fun play() { - player_node?.play() + val node = player_node ?: return + activate_session() + if (engine?.running != true && !start_engine()) return + // At the end of the track, play restarts from the beginning + if (_duration.value > 0 && _position.value >= _duration.value) seek(0) + node.play() play_start_mark = kotlin.time.TimeSource.Monotonic.markNow() play_start_position_ms = _position.value _playing.value = true start_position_updates() + update_now_playing() } + override fun pause() { + update_current_position() player_node?.pause() - _playing.value = false - stop_position_updates() + halt() } + 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 target = position_ms.coerceIn(0, _duration.value) val was_playing = _playing.value node.stop() - val frame = (position_ms.toDouble() / 1000.0 * file.processingFormat.sampleRate).toLong() + val frame = (target.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 + _position.value = target play_start_mark = kotlin.time.TimeSource.Monotonic.markNow() - play_start_position_ms = position_ms + play_start_position_ms = target if (was_playing) { node.play() - // Restart position monitoring to ensure it's running start_position_updates() } + update_now_playing() } override fun set_speed(rate: Float) { if (_playing.value) { + update_current_position() play_start_position_ms = _position.value play_start_mark = kotlin.time.TimeSource.Monotonic.markNow() } _speed.value = rate; time_pitch?.rate = rate + update_now_playing() } - override fun skip(delta_ms: Long) = seek((_position.value + delta_ms).coerceIn(0, _duration.value)) - override fun release() { release_engine() } - private fun release_engine() { + override fun unload() { stop_position_updates() player_node?.stop(); engine?.stop() engine = null; player_node = null; time_pitch = null; audio_file = null _playing.value = false + _position.value = 0 + _duration.value = 0 + interrupted_while_playing = false + MPNowPlayingInfoCenter.defaultCenter().nowPlayingInfo = null + } + + override fun release() { + unload() + observers.forEach { NSNotificationCenter.defaultCenter.removeObserver(it) } + observers.clear() + val commands = MPRemoteCommandCenter.sharedCommandCenter() + listOf(commands.playCommand, commands.pauseCommand, commands.togglePlayPauseCommand, commands.changePlaybackPositionCommand) + .forEach { it.removeTarget(null) } + scope.cancel() + } + + // Playback stopped without the node being asked to (pause, interruption, engine stop) + private fun halt() { + _playing.value = false + stop_position_updates() + update_now_playing() + } + + private fun start_engine(): Boolean { + val eng = engine ?: return false + return memScoped { + val err = alloc<ObjCObjectVar<NSError?>>() + val ok = eng.startAndReturnError(err.ptr) + if (!ok) println("Sonodrill: audio engine failed to start: ${err.value?.localizedDescription}") + ok + } + } + + private fun configure_session() { + memScoped { + val err = alloc<ObjCObjectVar<NSError?>>() + if (!AVAudioSession.sharedInstance().setCategory(AVAudioSessionCategoryPlayback, error = err.ptr)) + println("Sonodrill: failed to set audio session category: ${err.value?.localizedDescription}") + } + } + + private fun activate_session() { + memScoped { + val err = alloc<ObjCObjectVar<NSError?>>() + if (!AVAudioSession.sharedInstance().setActive(true, error = err.ptr)) + println("Sonodrill: failed to activate audio session: ${err.value?.localizedDescription}") + } } + + private fun observe_notifications() { + val center = NSNotificationCenter.defaultCenter + val queue = NSOperationQueue.mainQueue + observers += center.addObserverForName(AVAudioSessionInterruptionNotification, `object` = null, queue = queue) { note -> on_interruption(note) } + observers += center.addObserverForName(AVAudioSessionRouteChangeNotification, `object` = null, queue = queue) { note -> on_route_change(note) } + observers += center.addObserverForName(AVAudioEngineConfigurationChangeNotification, `object` = null, queue = queue) { _ -> on_engine_configuration_change() } + } + + private fun on_interruption(note: NSNotification?) { + val info = note?.userInfo ?: return + val type = (info[AVAudioSessionInterruptionTypeKey] as? NSNumber)?.unsignedLongValue ?: return + if (type == AVAudioSessionInterruptionTypeBegan) { + if (_playing.value) { + interrupted_while_playing = true + pause() + } + } else if (type == AVAudioSessionInterruptionTypeEnded) { + val options = (info[AVAudioSessionInterruptionOptionKey] as? NSNumber)?.unsignedLongValue ?: 0u + if (interrupted_while_playing && (options and AVAudioSessionInterruptionOptionShouldResume) != 0uL) play() + interrupted_while_playing = false + } + } + + private fun on_route_change(note: NSNotification?) { + val reason = (note?.userInfo?.get(AVAudioSessionRouteChangeReasonKey) as? NSNumber)?.unsignedLongValue ?: return + // Headphones unplugged or Bluetooth device gone: pause instead of switching to the speaker + if (reason == AVAudioSessionRouteChangeReasonOldDeviceUnavailable && _playing.value) pause() + } + + // The engine stops itself when the output changes (e.g. AirPods connecting): restart it where we were + private fun on_engine_configuration_change() { + if (engine == null) return + val was_playing = _playing.value + if (was_playing) { + update_current_position() + halt() + } + if (!start_engine()) return + seek(_position.value) + if (was_playing) play() + } + + private fun setup_remote_commands() { + val commands = MPRemoteCommandCenter.sharedCommandCenter() + commands.playCommand.addTargetWithHandler { _ -> remote { play() } } + commands.pauseCommand.addTargetWithHandler { _ -> remote { pause() } } + commands.togglePlayPauseCommand.addTargetWithHandler { _ -> remote { toggle() } } + commands.changePlaybackPositionCommand.addTargetWithHandler { event -> + val e = event as? MPChangePlaybackPositionCommandEvent + if (e == null) MPRemoteCommandHandlerStatusCommandFailed + else remote { seek((e.positionTime * 1000).toLong()) } + } + } + + private fun remote(action: () -> Unit): MPRemoteCommandHandlerStatus { + if (audio_file == null) return MPRemoteCommandHandlerStatusNoActionableNowPlayingItem + action() + return MPRemoteCommandHandlerStatusSuccess + } + + private fun update_now_playing() { + if (audio_file == null) return + MPNowPlayingInfoCenter.defaultCenter().nowPlayingInfo = mapOf<Any?, Any?>( + MPMediaItemPropertyTitle to title, + MPMediaItemPropertyPlaybackDuration to NSNumber(double = _duration.value / 1000.0), + MPNowPlayingInfoPropertyElapsedPlaybackTime to NSNumber(double = _position.value / 1000.0), + MPNowPlayingInfoPropertyPlaybackRate to NSNumber(double = if (_playing.value) _speed.value.toDouble() else 0.0), + ) + } + 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() { if (!_playing.value) return + // The engine can stop behind our back: don't let the position run on in silence + if (engine?.running != true) { + halt() + return + } val elapsed_ms = (play_start_mark.elapsedNow().inWholeMilliseconds * _speed.value).toLong() val pos = play_start_position_ms + elapsed_ms if (pos >= _duration.value) { _position.value = _duration.value - _playing.value = false - stop_position_updates() + halt() return } _position.value = pos } } -actual fun create_audio_player(): AudioPlayer = IosAudioPlayer() - actual fun extract_waveform(uri: String, num_samples: Int): FloatArray { val provider = waveform_provider ?: return FloatArray(0) - val list = provider.extractWaveform(uri, num_samples) + val list = provider.extractWaveform(resolve_audio_path(uri), num_samples) return FloatArray(list.size) { (list[it] as? Number)?.toFloat() ?: 0f } } diff --git a/composeApp/src/iosMain/kotlin/ubi2/sonodrill/data/ios_audio_storage.kt b/composeApp/src/iosMain/kotlin/ubi2/sonodrill/data/ios_audio_storage.kt @@ -0,0 +1,33 @@ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) +package ubi2.sonodrill.data + +import platform.Foundation.NSDocumentDirectory +import platform.Foundation.NSFileManager +import platform.Foundation.NSFileSize +import platform.Foundation.NSNumber +import platform.Foundation.NSSearchPathForDirectoriesInDomains +import platform.Foundation.NSUserDomainMask + +fun documents_dir(): String = + NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, true).first() as String + +fun audio_dir_path(): String = "${documents_dir()}/$AUDIO_DIR" + +actual fun resolve_audio_path(uri: String): String = if (uri.startsWith("/")) uri else "${documents_dir()}/$uri" + +actual fun audio_file_exists(uri: String): Boolean = NSFileManager.defaultManager.fileExistsAtPath(resolve_audio_path(uri)) + +actual fun delete_audio_file(uri: String) { + NSFileManager.defaultManager.removeItemAtPath(resolve_audio_path(uri), error = null) +} + +actual fun list_stored_audio(): List<String> = + (NSFileManager.defaultManager.contentsOfDirectoryAtPath(audio_dir_path(), error = null) ?: emptyList<Any?>()) + .mapNotNull { it as? String } + .filterNot { it.startsWith(".") } + .map { "$AUDIO_DIR/$it" } + +fun file_size(path: String): Long? { + val attrs = NSFileManager.defaultManager.attributesOfItemAtPath(path, error = null) ?: return null + return (attrs[NSFileSize] as? NSNumber)?.longLongValue +} diff --git a/composeApp/src/iosMain/kotlin/ubi2/sonodrill/main_ios.kt b/composeApp/src/iosMain/kotlin/ubi2/sonodrill/main_ios.kt @@ -1,4 +1,4 @@ -@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class, kotlinx.cinterop.BetaInteropApi::class) +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) package ubi2.sonodrill import androidx.compose.ui.window.ComposeUIViewController @@ -6,17 +6,16 @@ import platform.Foundation.* import platform.UIKit.* import platform.UniformTypeIdentifiers.UTTypeAudio import platform.darwin.NSObject +import ubi2.sonodrill.audio.IosAudioPlayer import ubi2.sonodrill.audio.WaveformProvider -import ubi2.sonodrill.audio.create_audio_player import ubi2.sonodrill.audio.waveform_provider -import ubi2.sonodrill.tip.TipProvider -import ubi2.sonodrill.tip.tip_provider +import ubi2.sonodrill.data.AUDIO_DIR import ubi2.sonodrill.data.AudioRepository import ubi2.sonodrill.data.DriverFactory +import ubi2.sonodrill.data.audio_dir_path +import ubi2.sonodrill.data.choose_import_name 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.data.file_size import ubi2.sonodrill.ui.SonodrillApp // prevent delegate from being garbage collected @@ -26,30 +25,32 @@ fun register_waveform_provider(provider: WaveformProvider) { waveform_provider = provider } -fun register_tip_provider(provider: TipProvider) { - tip_provider = provider -} - fun main_view_controller(): UIViewController { val db = create_database(DriverFactory()) val repo = AudioRepository(db) - val player = create_audio_player() + val player = IosAudioPlayer() - val controller = ComposeUIViewController { + return ComposeUIViewController { SonodrillApp( player = player, repo = repo, - on_import = { on_done -> - show_audio_picker(repo, on_done) - }, + on_import = { on_done -> show_audio_picker(repo, on_done) }, ) } +} - return controller +private fun top_view_controller(): UIViewController? { + val window = UIApplication.sharedApplication.connectedScenes + .mapNotNull { it as? UIWindowScene } + .flatMap { scene -> scene.windows.mapNotNull { it as? UIWindow } } + .firstOrNull { it.isKeyWindow() } + var vc = window?.rootViewController + while (vc?.presentedViewController != null) vc = vc.presentedViewController + return vc } private fun show_audio_picker(repo: AudioRepository, on_done: () -> Unit) { - val root = UIApplication.sharedApplication.keyWindow?.rootViewController ?: return + val root = top_view_controller() ?: return val picker = UIDocumentPickerViewController(forOpeningContentTypes = listOf(UTTypeAudio)) picker.allowsMultipleSelection = true @@ -67,39 +68,26 @@ internal class AudioPickerDelegate( 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) - } + val audio_dir = audio_dir_path() + file_manager.createDirectoryAtPath(audio_dir, withIntermediateDirectories = true, attributes = null, error = null) for (url in didPickDocumentsAtURLs) { val nsurl = url as? NSURL ?: continue - nsurl.startAccessingSecurityScopedResource() + val accessing = 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) + val name = nsurl.lastPathComponent ?: continue + val size = nsurl.path?.let { file_size(it) } ?: -1 + val (target, already_stored) = choose_import_name(name, size) { file_size("$audio_dir/$it") } + if (!already_stored) { + val copied = file_manager.copyItemAtURL(nsurl, toURL = NSURL.fileURLWithPath("$audio_dir/$target"), error = null) + if (!copied) { + println("Sonodrill: failed to import $name") + continue + } } - - 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) + repo.register_file("$AUDIO_DIR/$target") } finally { - nsurl.stopAccessingSecurityScopedResource() + if (accessing) nsurl.stopAccessingSecurityScopedResource() } } 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 @@ -3,26 +3,11 @@ package ubi2.sonodrill.model import platform.Foundation.NSDate import platform.Foundation.NSUUID import platform.Foundation.timeIntervalSince1970 +import platform.UIKit.UIApplication 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) -actual fun open_url(url: String) { - val nsurl = platform.Foundation.NSURL.URLWithString(url) ?: return - platform.UIKit.UIApplication.sharedApplication.openURL(nsurl, options = emptyMap<Any?, Any>(), completionHandler = null) -} actual fun set_idle_timer_disabled(disabled: Boolean) { - platform.UIKit.UIApplication.sharedApplication.idleTimerDisabled = disabled -} - -actual fun has_tipped(): Boolean = ubi2.sonodrill.tip.tip_provider?.hasTipped() ?: false -actual fun purchase_tip() { ubi2.sonodrill.tip.tip_provider?.purchase() } - -private const val PLAY_COUNT_KEY = "sonodrill_play_count" -actual fun get_play_count(): Int = - platform.Foundation.NSUserDefaults.standardUserDefaults.integerForKey(PLAY_COUNT_KEY).toInt() -actual fun increment_play_count() { - val defaults = platform.Foundation.NSUserDefaults.standardUserDefaults - defaults.setInteger((defaults.integerForKey(PLAY_COUNT_KEY) + 1), PLAY_COUNT_KEY) + UIApplication.sharedApplication.idleTimerDisabled = disabled } diff --git a/composeApp/src/iosMain/kotlin/ubi2/sonodrill/sync/ios_sync.kt b/composeApp/src/iosMain/kotlin/ubi2/sonodrill/sync/ios_sync.kt @@ -1,14 +0,0 @@ -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/composeApp/src/iosMain/kotlin/ubi2/sonodrill/tip/tip_provider.kt b/composeApp/src/iosMain/kotlin/ubi2/sonodrill/tip/tip_provider.kt @@ -1,8 +0,0 @@ -package ubi2.sonodrill.tip - -interface TipProvider { - fun hasTipped(): Boolean - fun purchase() -} - -var tip_provider: TipProvider? = null diff --git a/composeApp/src/iosMain/kotlin/ubi2/sonodrill/ui/ios_platform_ui.kt b/composeApp/src/iosMain/kotlin/ubi2/sonodrill/ui/ios_platform_ui.kt @@ -0,0 +1,6 @@ +package ubi2.sonodrill.ui + +import androidx.compose.runtime.Composable + +@Composable +actual fun PlatformBackHandler(on_back: () -> Unit) {} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml @@ -5,8 +5,7 @@ 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" +activity-compose = "1.9.3" [libraries] kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } @@ -18,12 +17,8 @@ sqldelight-android = { module = "app.cash.sqldelight:android-driver", version.re 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" } +activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity-compose" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } diff --git a/iosApp/ContentView.swift b/iosApp/ContentView.swift @@ -11,7 +11,6 @@ struct ContentView: View { struct ComposeViewControllerRepresentable: UIViewControllerRepresentable { func makeUIViewController(context: Context) -> UIViewController { Main_iosKt.register_waveform_provider(provider: SwiftWaveformProvider()) - Main_iosKt.register_tip_provider(provider: SwiftTipProvider()) return Main_iosKt.main_view_controller() } func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} diff --git a/iosApp/Info.plist b/iosApp/Info.plist @@ -21,7 +21,7 @@ <key>CFBundleShortVersionString</key> <string>1.0</string> <key>CFBundleVersion</key> - <string>1</string> + <string>202604071051</string> <key>LSRequiresIPhoneOS</key> <true/> <key>UIApplicationSceneManifest</key> @@ -29,6 +29,10 @@ <key>UIApplicationSupportsMultipleScenes</key> <false/> </dict> + <key>UIBackgroundModes</key> + <array> + <string>audio</string> + </array> <key>UILaunchScreen</key> <dict/> <key>UISupportedInterfaceOrientations</key> diff --git a/iosApp/TipManager.swift b/iosApp/TipManager.swift @@ -1,65 +0,0 @@ -import StoreKit -import ComposeApp - -class SwiftTipProvider: TipProvider { - static let productId = "ubi2.sonodrill.tip" - private var purchased = false - private var product: Product? - - init() { - self.product = nil - Task { - await loadProduct() - await checkPurchased() - } - } - - private func loadProduct() async { - do { - let products = try await Product.products(for: [SwiftTipProvider.productId]) - self.product = products.first - } catch { - print("Sonodrill: failed to load tip product: \(error)") - } - } - - private func checkPurchased() async { - for await result in Transaction.currentEntitlements { - if case .verified(let tx) = result, tx.productID == SwiftTipProvider.productId { - purchased = true - return - } - } - } - - func hasTipped() -> Bool { - return purchased - } - - func purchase() { - Task { - guard let product = self.product else { - print("Sonodrill: tip product not loaded") - return - } - do { - let result = try await product.purchase() - switch result { - case .success(let verification): - if case .verified(let tx) = verification { - await tx.finish() - purchased = true - } - case .userCancelled: - break - case .pending: - break - @unknown default: - break - } - } catch { - print("Sonodrill: purchase failed: \(error)") - } - } - } -} diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -11,13 +11,11 @@ 2E8A151C3EFF8D2645EFFBFA /* SonodrillApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = E74C38E65621F5192E1B68D2 /* SonodrillApp.swift */; }; 47808750E34BFBC08FE160DA /* WaveformExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BCF6AAC471512DFD0FA06EE /* WaveformExtractor.swift */; }; 56B0305B5D3847FC6B326502 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8509E5A49E4A5CAAEB3ADB /* ContentView.swift */; }; - 957C4BEC91B3575B6F6D7D5C /* TipManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201A2D6E0D8E19221257318F /* TipManager.swift */; }; AD99F711E95E895398A8473F /* ExportOptions.plist in Resources */ = {isa = PBXBuildFile; fileRef = 9249877C37C47F33E2749A7B /* ExportOptions.plist */; }; F7C33B50A94DA01F2E82C280 /* libsqlite3.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 245434685F34A9A15B1AC406 /* libsqlite3.tbd */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ - 201A2D6E0D8E19221257318F /* TipManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TipManager.swift; sourceTree = "<group>"; }; 245434685F34A9A15B1AC406 /* libsqlite3.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libsqlite3.tbd; path = usr/lib/libsqlite3.tbd; sourceTree = SDKROOT; }; 2BCF6AAC471512DFD0FA06EE /* WaveformExtractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaveformExtractor.swift; sourceTree = "<group>"; }; 86038AE1032E284E79B32AD6 /* iosApp.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = iosApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -46,7 +44,6 @@ CE8509E5A49E4A5CAAEB3ADB /* ContentView.swift */, 9249877C37C47F33E2749A7B /* ExportOptions.plist */, E74C38E65621F5192E1B68D2 /* SonodrillApp.swift */, - 201A2D6E0D8E19221257318F /* TipManager.swift */, 2BCF6AAC471512DFD0FA06EE /* WaveformExtractor.swift */, ); name = iosApp; @@ -175,7 +172,6 @@ files = ( 56B0305B5D3847FC6B326502 /* ContentView.swift in Sources */, 2E8A151C3EFF8D2645EFFBFA /* SonodrillApp.swift in Sources */, - 957C4BEC91B3575B6F6D7D5C /* TipManager.swift in Sources */, 47808750E34BFBC08FE160DA /* WaveformExtractor.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -322,6 +318,8 @@ "-framework", ComposeApp, "-lsqlite3", + "-framework", + MediaPlayer, ); PRODUCT_BUNDLE_IDENTIFIER = ubi2.sonodrill; PRODUCT_NAME = Sonodrill; @@ -348,6 +346,8 @@ "-framework", ComposeApp, "-lsqlite3", + "-framework", + MediaPlayer, ); PRODUCT_BUNDLE_IDENTIFIER = ubi2.sonodrill; PRODUCT_NAME = Sonodrill; 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/project.yml b/iosApp/project.yml @@ -27,6 +27,8 @@ targets: UIApplicationSceneManifest: UIApplicationSupportsMultipleScenes: false UILaunchScreen: {} + UIBackgroundModes: + - audio UISupportedInterfaceOrientations: - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown @@ -45,6 +47,8 @@ targets: - "-framework" - "ComposeApp" - "-lsqlite3" + - "-framework" + - "MediaPlayer" dependencies: - sdk: libsqlite3.tbd preBuildScripts: