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 bed2e8104f7aafb7fb1e216e38fb5230b7cb7fae
parent d127d7e444890535ce2229821e55b166d168ecbc
Author: Jean Schurger <jean@schurger.org>
Date:   Wed,  1 Apr 2026 14:41:10 -0400

Add mark labels, quick loop, idle timer, and UI polish

- Tap mark time label to name it (shown below the time)
- Mark loop button: 3 states (active/normal/add-mark-and-loop)
- Mark loop no longer seeks to start, continues playback
- Prevent device sleep during playback (idleTimerDisabled)
- Mark labels centered with edge clamping
- Tighter padding on mark labels

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Diffstat:
McomposeApp/src/androidMain/kotlin/ubi2/sonodrill/model/android_platform.kt | 1+
McomposeApp/src/commonMain/kotlin/ubi2/sonodrill/audio/loop_controller.kt | 4++--
McomposeApp/src/commonMain/kotlin/ubi2/sonodrill/model/platform.kt | 1+
McomposeApp/src/commonMain/kotlin/ubi2/sonodrill/ui/player/player_screen.kt | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
McomposeApp/src/commonMain/kotlin/ubi2/sonodrill/ui/player/player_view_model.kt | 27++++++++++++++++++++++++++-
McomposeApp/src/iosMain/kotlin/ubi2/sonodrill/model/ios_platform.kt | 4++++
6 files changed, 121 insertions(+), 15 deletions(-)

diff --git a/composeApp/src/androidMain/kotlin/ubi2/sonodrill/model/android_platform.kt b/composeApp/src/androidMain/kotlin/ubi2/sonodrill/model/android_platform.kt @@ -6,5 +6,6 @@ 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() {} diff --git a/composeApp/src/commonMain/kotlin/ubi2/sonodrill/audio/loop_controller.kt b/composeApp/src/commonMain/kotlin/ubi2/sonodrill/audio/loop_controller.kt @@ -14,10 +14,10 @@ class LoopController(private val player: AudioPlayer) { private var loop_job: Job? = null private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - fun set_loop(a: MarkInfo, b: MarkInfo) { + 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 _state.value = LoopState(mark_a = start, mark_b = end, enabled = true) - player.seek(start.position_ms) + if (seek_to_start) player.seek(start.position_ms) start_monitoring() } diff --git a/composeApp/src/commonMain/kotlin/ubi2/sonodrill/model/platform.kt b/composeApp/src/commonMain/kotlin/ubi2/sonodrill/model/platform.kt @@ -8,3 +8,4 @@ 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/ui/player/player_screen.kt b/composeApp/src/commonMain/kotlin/ubi2/sonodrill/ui/player/player_screen.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.unit.sp import ubi2.sonodrill.model.LoopState import ubi2.sonodrill.model.MarkInfo import ubi2.sonodrill.model.SkipZone +import ubi2.sonodrill.model.set_idle_timer_disabled @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -47,9 +48,13 @@ fun PlayerScreen(vm: PlayerViewModel, on_back: () -> Unit) { val play_delay_enabled by vm.play_delay_enabled.collectAsState() val countdown by vm.countdown.collectAsState() + LaunchedEffect(playing) { set_idle_timer_disabled(playing) } + DisposableEffect(Unit) { onDispose { set_idle_timer_disabled(false) } } + var show_speed_sheet by remember { mutableStateOf(false) } var show_rename by remember { mutableStateOf(false) } var show_delay_picker by remember { mutableStateOf(false) } + var mark_to_rename by remember { mutableStateOf<MarkInfo?>(null) } Scaffold( topBar = { @@ -90,6 +95,7 @@ fun PlayerScreen(vm: PlayerViewModel, on_back: () -> Unit) { } } }, + on_mark_label_click = { mark -> mark_to_rename = mark }, ) } @@ -171,14 +177,26 @@ fun PlayerScreen(vm: PlayerViewModel, on_back: () -> Unit) { } } - // Mark loop toggle + // Mark loop toggle (3 states) + val has_prev_mark = marks.any { it.position_ms <= position } + val has_next_mark = marks.any { it.position_ms > position } + val can_quick_loop = has_prev_mark && !has_next_mark + if (mark_loop_active) { - FilledIconButton(onClick = { vm.toggle_mark_loop() }, enabled = marks.size >= 2) { + FilledIconButton(onClick = { vm.toggle_mark_loop() }) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy((-2).dp)) { Icon(Icons.Default.Repeat, null, modifier = Modifier.size(20.dp)) Icon(Icons.Default.Bookmark, null, modifier = Modifier.size(12.dp)) } } + } else if (can_quick_loop) { + // Has a mark before but none after: add mark here + loop + OutlinedIconButton(onClick = { vm.add_mark_and_loop() }, enabled = has_prev_mark) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy((-2).dp)) { + Icon(Icons.Default.Repeat, null, modifier = Modifier.size(20.dp)) + Icon(Icons.Default.BookmarkAdd, null, modifier = Modifier.size(14.dp)) + } + } } else { OutlinedIconButton(onClick = { vm.toggle_mark_loop() }, enabled = marks.size >= 2) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy((-2).dp)) { @@ -251,6 +269,33 @@ fun PlayerScreen(vm: PlayerViewModel, on_back: () -> Unit) { ) } + // Mark rename dialog + mark_to_rename?.let { mark -> + var label by remember(mark.id) { mutableStateOf(mark.label ?: "") } + AlertDialog( + onDismissRequest = { mark_to_rename = null }, + title = { Text("Name mark at ${format_time_centis(mark.position_ms)}") }, + text = { + OutlinedTextField( + value = label, + onValueChange = { label = it }, + singleLine = true, + placeholder = { Text("e.g. Chorus, Bridge...") }, + modifier = Modifier.fillMaxWidth(), + ) + }, + confirmButton = { + TextButton(onClick = { + vm.rename_mark(mark.id, label.ifBlank { null }) + mark_to_rename = null + }) { Text("OK") } + }, + dismissButton = { + TextButton(onClick = { mark_to_rename = null }) { Text("Cancel") } + }, + ) + } + // Delay picker dialog if (show_delay_picker) { val delays = listOf(1, 2, 3, 5, 10) @@ -293,6 +338,7 @@ private fun ProgressTimeline( waveform: FloatArray, skip_zones: List<SkipZone>, on_seek: (Long) -> Unit, + on_mark_label_click: (MarkInfo) -> Unit, ) { val primary = MaterialTheme.colorScheme.primary val surface_variant = MaterialTheme.colorScheme.surfaceVariant @@ -344,6 +390,7 @@ private fun ProgressTimeline( primary = primary, surface_variant = surface_variant, on_surface = on_surface, mark_color = mark_color, loop_color = loop_color, skip_color = skip_color, skip_zones = skip_zones, + on_mark_label_click = on_mark_label_click, modifier = Modifier.fillMaxWidth().height(ROW_HEIGHT_DP.dp), ) } @@ -362,6 +409,7 @@ private fun ProgressTimeline( primary = primary, surface_variant = surface_variant, on_surface = on_surface, mark_color = mark_color, loop_color = loop_color, skip_color = skip_color, skip_zones = skip_zones, + on_mark_label_click = on_mark_label_click, modifier = Modifier.fillMaxWidth().weight(1f), ) } @@ -378,6 +426,7 @@ private fun TimelineRow( primary: Color, surface_variant: Color, on_surface: Color, mark_color: Color, loop_color: Color, skip_color: Color, skip_zones: List<SkipZone>, + on_mark_label_click: (MarkInfo) -> Unit, modifier: Modifier, ) { val row_start_ms = row * row_duration @@ -489,19 +538,45 @@ private fun TimelineRow( // Mark time labels BoxWithConstraints(modifier = Modifier.fillMaxSize()) { val total_width = maxWidth + val est_width = 64.dp // estimated max label width for (mark in row_marks) { val mark_frac = (mark.position_ms - row_start_ms) / (row_end_ms - row_start_ms) - val offset_x = total_width * mark_frac - Text( - text = format_time_centis(mark.position_ms), - fontSize = 11.sp, - fontWeight = FontWeight.Bold, - color = Color(0xFFCC3300), + val center_x = total_width * mark_frac + val offset_x = (center_x - est_width / 2) + .coerceAtLeast(0.dp) + .coerceAtMost((total_width - est_width).coerceAtLeast(0.dp)) + Column( + horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier - .offset(x = (offset_x - 22.dp).coerceAtLeast(0.dp), y = 1.dp) - .background(surface_variant.copy(alpha = 0.85f), RoundedCornerShape(3.dp)) - .padding(horizontal = 3.dp, vertical = 1.dp), - ) + .width(est_width) + .offset(x = offset_x, y = 1.dp) + .clickable { on_mark_label_click(mark) }, + ) { + Text( + text = format_time_centis(mark.position_ms), + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFFCC3300), + maxLines = 1, + lineHeight = 12.sp, + modifier = Modifier + .background(surface_variant.copy(alpha = 0.85f), RoundedCornerShape(2.dp)) + .padding(horizontal = 2.dp, vertical = 0.dp), + ) + if (!mark.label.isNullOrEmpty()) { + Text( + text = mark.label!!, + fontSize = 9.sp, + lineHeight = 10.sp, + color = Color(0xFFCC3300).copy(alpha = 0.8f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .background(surface_variant.copy(alpha = 0.85f), RoundedCornerShape(3.dp)) + .padding(horizontal = 2.dp, vertical = 0.dp), + ) + } + } } } } 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 @@ -113,6 +113,13 @@ class PlayerViewModel( _marks.value = repo.get_marks(file.id) } + 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)) + _marks.value = repo.get_marks(file.id) + } + fun delete_mark(id: String) { val file = _current_file.value ?: return repo.delete_mark(id) @@ -183,6 +190,24 @@ class PlayerViewModel( } } + 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) { + loop.set_loop(prev, new_mark, seek_to_start = false) + } + } + fun toggle_mark_loop() { if (loop_state.value.enabled && loop_state.value.mark_a?.id != "_full_a") { loop.clear() @@ -193,7 +218,7 @@ class PlayerViewModel( val prev = sorted.lastOrNull { it.position_ms <= pos + 500 } val next = sorted.firstOrNull { it.position_ms > pos - 500 && it != prev } if (prev != null && next != null) { - loop.set_loop(prev, next) + loop.set_loop(prev, next, seek_to_start = false) } } diff --git a/composeApp/src/iosMain/kotlin/ubi2/sonodrill/model/ios_platform.kt b/composeApp/src/iosMain/kotlin/ubi2/sonodrill/model/ios_platform.kt @@ -12,6 +12,10 @@ actual fun open_url(url: String) { 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() }