commit 2bee00465c559bda184ee24260b26af75b3d2c6f
parent bed2e8104f7aafb7fb1e216e38fb5230b7cb7fae
Author: Jean Schurger <jean@schurger.org>
Date: Mon, 6 Apr 2026 16:41:46 -0400
Add loop zones, Bluetooth fix, delay options, and UI tweaks
- Multiple loop zones (like skip zones), tap to activate/deactivate
- Loop zones shown as blue tint on timeline
- Fix position tracking over Bluetooth (use monotonic clock)
- Restart position monitoring on seek
- Delay picker options: 2, 5, 15, 30 seconds
- Prev button goes to start if no previous mark
- Next button disabled when no next mark
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Diffstat:
5 files changed, 151 insertions(+), 27 deletions(-)
diff --git a/composeApp/src/commonMain/kotlin/ubi2/sonodrill/model/models.kt b/composeApp/src/commonMain/kotlin/ubi2/sonodrill/model/models.kt
@@ -29,3 +29,8 @@ data class SkipZone(
val start_ms: Long,
val end_ms: Long,
)
+
+data class LoopZone(
+ val start_ms: Long,
+ val end_ms: Long,
+)
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
@@ -27,6 +27,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import ubi2.sonodrill.model.LoopState
+import ubi2.sonodrill.model.LoopZone
import ubi2.sonodrill.model.MarkInfo
import ubi2.sonodrill.model.SkipZone
import ubi2.sonodrill.model.set_idle_timer_disabled
@@ -43,6 +44,7 @@ fun PlayerScreen(vm: PlayerViewModel, on_back: () -> Unit) {
val file by vm.current_file.collectAsState()
val waveform by vm.waveform.collectAsState()
val skip_zones by vm.skip_zones.collectAsState()
+ val loop_zones by vm.loop_zones.collectAsState()
val play_delay_s by vm.play_delay_s.collectAsState()
val play_delay_enabled by vm.play_delay_enabled.collectAsState()
@@ -80,10 +82,18 @@ fun PlayerScreen(vm: PlayerViewModel, on_back: () -> Unit) {
position_ms = position,
duration_ms = duration,
marks = marks,
- loop_state = loop_state,
waveform = waveform,
skip_zones = skip_zones,
+ loop_zones = loop_zones,
+ loop_state = loop_state,
on_seek = { tap_ms ->
+ // Check if tapping inside a loop zone first
+ val tapped_zone = loop_zones.firstOrNull { tap_ms >= it.start_ms && tap_ms < it.end_ms }
+ if (tapped_zone != null) {
+ vm.tap_in_loop_zone(tap_ms)
+ vm.seek(tap_ms)
+ return@ProgressTimeline
+ }
vm.seek(tap_ms)
// If mark loop is active and tap is outside current loop, re-loop around tap position
val ls = loop_state
@@ -138,7 +148,10 @@ fun PlayerScreen(vm: PlayerViewModel, on_back: () -> Unit) {
)
}
}
- IconButton(onClick = { vm.next_mark() }) {
+ IconButton(
+ onClick = { vm.next_mark() },
+ enabled = marks.any { it.position_ms > position + 500 },
+ ) {
Icon(Icons.Default.SkipNext, "Next mark")
}
@@ -298,7 +311,7 @@ fun PlayerScreen(vm: PlayerViewModel, on_back: () -> Unit) {
// Delay picker dialog
if (show_delay_picker) {
- val delays = listOf(1, 2, 3, 5, 10)
+ val delays = listOf(2, 5, 15, 30)
AlertDialog(
onDismissRequest = { show_delay_picker = false },
title = { Text("Play delay") },
@@ -308,7 +321,8 @@ fun PlayerScreen(vm: PlayerViewModel, on_back: () -> Unit) {
FilterChip(
selected = play_delay_s == d,
onClick = { vm.set_play_delay(d) },
- label = { Text("${d}s") },
+ label = { Text("${d}s", maxLines = 1) },
+ modifier = Modifier.padding(horizontal = 2.dp),
)
}
}
@@ -334,9 +348,10 @@ private fun ProgressTimeline(
position_ms: Long,
duration_ms: Long,
marks: List<MarkInfo>,
- loop_state: LoopState,
waveform: FloatArray,
skip_zones: List<SkipZone>,
+ loop_zones: List<LoopZone>,
+ loop_state: LoopState,
on_seek: (Long) -> Unit,
on_mark_label_click: (MarkInfo) -> Unit,
) {
@@ -346,6 +361,8 @@ private fun ProgressTimeline(
val mark_color = Color(0xFFFF6B35)
val loop_color = MaterialTheme.colorScheme.primaryContainer
val skip_color = Color(0x33FF0000)
+ val loop_zone_color = Color(0x220066FF)
+ val loop_zone_active_color = Color(0x440066FF)
var zoom by remember { mutableFloatStateOf(1f) }
val rows = (BASE_ROWS * zoom).toInt().coerceAtLeast(BASE_ROWS)
@@ -386,10 +403,12 @@ private fun ProgressTimeline(
TimelineRow(
row = row, row_duration = row_duration, samples_per_row = samples_per_row,
position_ms = position_ms, duration_ms = duration_ms, marks = marks,
- loop_state = loop_state, waveform = waveform, on_seek = on_seek,
+ waveform = waveform, on_seek = on_seek,
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,
+ loop_zones = loop_zones, loop_state = loop_state,
+ loop_zone_color = loop_zone_color, loop_zone_active_color = loop_zone_active_color,
on_mark_label_click = on_mark_label_click,
modifier = Modifier.fillMaxWidth().height(ROW_HEIGHT_DP.dp),
)
@@ -405,10 +424,12 @@ private fun ProgressTimeline(
TimelineRow(
row = row, row_duration = row_duration, samples_per_row = samples_per_row,
position_ms = position_ms, duration_ms = duration_ms, marks = marks,
- loop_state = loop_state, waveform = waveform, on_seek = on_seek,
+ waveform = waveform, on_seek = on_seek,
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,
+ loop_zones = loop_zones, loop_state = loop_state,
+ loop_zone_color = loop_zone_color, loop_zone_active_color = loop_zone_active_color,
on_mark_label_click = on_mark_label_click,
modifier = Modifier.fillMaxWidth().weight(1f),
)
@@ -422,10 +443,12 @@ private fun ProgressTimeline(
private fun TimelineRow(
row: Int, row_duration: Float, samples_per_row: Float,
position_ms: Long, duration_ms: Long, marks: List<MarkInfo>,
- loop_state: LoopState, waveform: FloatArray, on_seek: (Long) -> Unit,
+ waveform: FloatArray, on_seek: (Long) -> Unit,
primary: Color, surface_variant: Color, on_surface: Color,
mark_color: Color, loop_color: Color, skip_color: Color,
skip_zones: List<SkipZone>,
+ loop_zones: List<LoopZone>, loop_state: LoopState,
+ loop_zone_color: Color, loop_zone_active_color: Color,
on_mark_label_click: (MarkInfo) -> Unit,
modifier: Modifier,
) {
@@ -473,6 +496,20 @@ private fun TimelineRow(
}
}
+ // Loop zones (blue tint)
+ for (zone in loop_zones) {
+ val zs = ((zone.start_ms - row_start_ms) / (row_end_ms - row_start_ms)).coerceIn(0f, 1f)
+ val ze = ((zone.end_ms - row_start_ms) / (row_end_ms - row_start_ms)).coerceIn(0f, 1f)
+ if (ze > zs && zone.start_ms < row_end_ms && zone.end_ms > row_start_ms) {
+ val is_active = loop_state.enabled && loop_state.mark_a?.position_ms == zone.start_ms && loop_state.mark_b?.position_ms == zone.end_ms
+ drawRect(
+ color = if (is_active) loop_zone_active_color else loop_zone_color,
+ topLeft = Offset(zs * w, 0f),
+ size = androidx.compose.ui.geometry.Size((ze - zs) * w, h),
+ )
+ }
+ }
+
// Skip zones (red tint)
for (zone in skip_zones) {
val zs = ((zone.start_ms - row_start_ms) / (row_end_ms - row_start_ms)).coerceIn(0f, 1f)
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
@@ -6,6 +6,7 @@ import ubi2.sonodrill.audio.extract_waveform
import ubi2.sonodrill.data.AudioRepository
import ubi2.sonodrill.model.AudioFileInfo
import ubi2.sonodrill.model.LoopState
+import ubi2.sonodrill.model.LoopZone
import ubi2.sonodrill.model.MarkInfo
import ubi2.sonodrill.model.SkipZone
import ubi2.sonodrill.model.current_time_ms
@@ -37,6 +38,9 @@ class PlayerViewModel(
private val _skip_zones = MutableStateFlow<List<SkipZone>>(emptyList())
val skip_zones: StateFlow<List<SkipZone>> = _skip_zones.asStateFlow()
+ private val _loop_zones = MutableStateFlow<List<LoopZone>>(emptyList())
+ val loop_zones: StateFlow<List<LoopZone>> = _loop_zones.asStateFlow()
+
private val _play_delay_s = MutableStateFlow(3)
val play_delay_s: StateFlow<Int> = _play_delay_s.asStateFlow()
private val _play_delay_enabled = MutableStateFlow(false)
@@ -158,7 +162,8 @@ class PlayerViewModel(
fun prev_mark() {
val pos = position.value
- _marks.value.lastOrNull { it.position_ms < pos - 500 }?.let { seek_to_mark(it) }
+ val prev = _marks.value.lastOrNull { it.position_ms < pos - 500 }
+ if (prev != null) seek_to_mark(prev) else player.seek(0)
}
fun delete_prev_mark() {
@@ -182,6 +187,12 @@ class PlayerViewModel(
}
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
+ }
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 }
@@ -204,21 +215,62 @@ class PlayerViewModel(
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)
+ 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)
}
}
fun toggle_mark_loop() {
+ // If loop is active, deactivate
if (loop_state.value.enabled && loop_state.value.mark_a?.id != "_full_a") {
loop.clear()
return
}
+ // Find surrounding marks and create/activate a loop zone
val pos = position.value
val sorted = _marks.value.sortedBy { it.position_ms }
val prev = sorted.lastOrNull { it.position_ms <= pos + 500 }
val next = sorted.firstOrNull { it.position_ms > pos - 500 && it != prev }
if (prev != null && next != null) {
- loop.set_loop(prev, next, seek_to_start = false)
+ 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 }
+ }
+ 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)
+ }
+
+ 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)
+ }
}
}
@@ -239,6 +291,9 @@ class PlayerViewModel(
_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)
+ }
}
fun add_skip_zone() {
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
@@ -28,6 +28,8 @@ class IosAudioPlayer : AudioPlayer {
private var position_job: Job? = null
private var seek_offset_ms: Long = 0
+ private var play_start_mark = kotlin.time.TimeSource.Monotonic.markNow()
+ private var play_start_position_ms: Long = 0
override fun load(uri: String) {
release_engine()
@@ -67,13 +69,24 @@ class IosAudioPlayer : AudioPlayer {
node.scheduleFile(file, atTime = null, completionHandler = null)
}
- override fun play() { player_node?.play(); _playing.value = true; start_position_updates() }
- override fun pause() { player_node?.pause(); _playing.value = false; stop_position_updates() }
+ override fun play() {
+ player_node?.play()
+ play_start_mark = kotlin.time.TimeSource.Monotonic.markNow()
+ play_start_position_ms = _position.value
+ _playing.value = true
+ start_position_updates()
+ }
+ override fun pause() {
+ player_node?.pause()
+ _playing.value = false
+ stop_position_updates()
+ }
override fun toggle() { if (_playing.value) pause() else play() }
override fun seek(position_ms: Long) {
val file = audio_file ?: return; val node = player_node ?: return
- val was_playing = _playing.value; node.stop()
+ val was_playing = _playing.value
+ node.stop()
val frame = (position_ms.toDouble() / 1000.0 * file.processingFormat.sampleRate).toLong()
val remaining = file.length - frame
if (remaining > 0) {
@@ -81,10 +94,22 @@ class IosAudioPlayer : AudioPlayer {
}
seek_offset_ms = position_ms
_position.value = position_ms
- if (was_playing) node.play()
+ play_start_mark = kotlin.time.TimeSource.Monotonic.markNow()
+ play_start_position_ms = position_ms
+ if (was_playing) {
+ node.play()
+ // Restart position monitoring to ensure it's running
+ start_position_updates()
+ }
}
- override fun set_speed(rate: Float) { _speed.value = rate; time_pitch?.rate = rate }
+ override fun set_speed(rate: Float) {
+ if (_playing.value) {
+ play_start_position_ms = _position.value
+ play_start_mark = kotlin.time.TimeSource.Monotonic.markNow()
+ }
+ _speed.value = rate; time_pitch?.rate = rate
+ }
override fun skip(delta_ms: Long) = seek((_position.value + delta_ms).coerceIn(0, _duration.value))
override fun release() { release_engine(); scope.cancel() }
@@ -93,10 +118,8 @@ class IosAudioPlayer : AudioPlayer {
private fun stop_position_updates() { position_job?.cancel(); position_job = null }
private fun update_current_position() {
- val node = player_node ?: return; val file = audio_file ?: return
- val time = node.lastRenderTime ?: return; val pt = node.playerTimeForNodeTime(time) ?: return
- val elapsed_ms = ((pt.sampleTime.toDouble() / file.processingFormat.sampleRate) * 1000).toLong()
- val pos = seek_offset_ms + elapsed_ms
+ 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
diff --git a/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme b/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme
@@ -1,10 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1430"
- version = "1.3">
+ version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
- buildImplicitDependencies = "YES">
+ buildImplicitDependencies = "YES"
+ runPostActionsOnFailure = "NO">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
@@ -15,7 +16,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "78B53CC79860CBFC2F5543C6"
- BuildableName = "Sonodrill.app"
+ BuildableName = "iosApp.app"
BlueprintName = "iosApp"
ReferencedContainer = "container:iosApp.xcodeproj">
</BuildableReference>
@@ -26,12 +27,13 @@
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
- shouldUseLaunchSchemeArgsEnv = "YES">
+ shouldUseLaunchSchemeArgsEnv = "YES"
+ onlyGenerateCoverageForSpecifiedTargets = "NO">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "78B53CC79860CBFC2F5543C6"
- BuildableName = "Sonodrill.app"
+ BuildableName = "iosApp.app"
BlueprintName = "iosApp"
ReferencedContainer = "container:iosApp.xcodeproj">
</BuildableReference>
@@ -54,11 +56,13 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "78B53CC79860CBFC2F5543C6"
- BuildableName = "Sonodrill.app"
+ BuildableName = "iosApp.app"
BlueprintName = "iosApp"
ReferencedContainer = "container:iosApp.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
+ <CommandLineArguments>
+ </CommandLineArguments>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
@@ -71,7 +75,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "78B53CC79860CBFC2F5543C6"
- BuildableName = "Sonodrill.app"
+ BuildableName = "iosApp.app"
BlueprintName = "iosApp"
ReferencedContainer = "container:iosApp.xcodeproj">
</BuildableReference>