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:
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() }