From face2edb8d9ba1147c28cf9f2d78fff2bac15796 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 2 Oct 2026 22:33:20 +0200 Subject: [PATCH] fix(ringtones): let a sound preview be stopped The picker's play button only ever started a preview; nothing but taking a row, playing another one or leaving the picker stopped it. The previewer now publishes what it is sounding, cleared on stop and when the tone ends, and the playing row's button becomes a stop button. --- .../data/ringtones/RingtonePreviewer.kt | 5 +++++ .../data/ringtones/SystemRingtonePreviewer.kt | 14 +++++++++++- .../clockula/ui/alarms/AlarmEditorScreen.kt | 2 ++ .../ui/alarms/AlarmEditorViewModel.kt | 3 +++ .../ui/common/RingtonePickerScreen.kt | 22 +++++++++++++++++-- .../clockula/ui/settings/SettingsScreen.kt | 2 ++ .../clockula/ui/settings/SettingsViewModel.kt | 3 +++ .../clockula/ui/timers/TimerEditorScreen.kt | 2 ++ .../ui/timers/TimerEditorViewModel.kt | 3 +++ app/src/main/res/values/strings.xml | 1 + .../clockula/testing/FakeRingtones.kt | 5 +++++ 11 files changed, 59 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/ringtones/RingtonePreviewer.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/ringtones/RingtonePreviewer.kt index 60cc433..29e223c 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/ringtones/RingtonePreviewer.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/ringtones/RingtonePreviewer.kt @@ -1,6 +1,11 @@ package de.jeanlucmakiola.clockula.data.ringtones +import kotlinx.coroutines.flow.StateFlow + interface RingtonePreviewer { + /** The URI sounding right now, or null — cleared by [stop] and when the tone finishes. */ + val playing: StateFlow + /** Plays [uri] once over USAGE_ALARM, stopping anything already playing. */ suspend fun play(uri: String) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/ringtones/SystemRingtonePreviewer.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/ringtones/SystemRingtonePreviewer.kt index 09fc8bf..357312c 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/ringtones/SystemRingtonePreviewer.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/ringtones/SystemRingtonePreviewer.kt @@ -8,6 +8,9 @@ import dagger.hilt.android.qualifiers.ApplicationContext import de.jeanlucmakiola.clockula.domain.MediaSources import de.jeanlucmakiola.floret.di.IoDispatcher import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext @@ -38,6 +41,10 @@ class SystemRingtonePreviewer @Inject constructor( @Volatile private var player: MediaPlayer? = null + private val _playing = MutableStateFlow(null) + + override val playing: StateFlow = _playing.asStateFlow() + /** Set by [stop], so a player that finished preparing after it is released too. */ @Volatile private var stopped: Boolean = false @@ -74,9 +81,13 @@ class SystemRingtonePreviewer @Inject constructor( return } player = prepared + _playing.value = uri prepared.setOnCompletionListener { finished -> release(finished) - if (player === finished) player = null + if (player === finished) { + player = null + _playing.value = null + } } runCatching { prepared.start() } } @@ -87,6 +98,7 @@ class SystemRingtonePreviewer @Inject constructor( stopped = true player?.let(::release) player = null + _playing.value = null } private fun prepare(uri: Uri): MediaPlayer? { diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorScreen.kt index 41bd383..3e4a3dc 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorScreen.kt @@ -56,6 +56,7 @@ fun AlarmEditorScreen( ) { val state by viewModel.state.collectAsStateWithLifecycle() val picker by viewModel.ringtonePicker.collectAsStateWithLifecycle() + val previewPlaying by viewModel.previewPlaying.collectAsStateWithLifecycle() var confirmingDelete by rememberSaveable { mutableStateOf(false) } val formatTime = rememberAlarmTimeFormatter() @@ -120,6 +121,7 @@ fun AlarmEditorScreen( state = pickerState, defaultUri = editing?.defaults?.alarmRingtoneUri, onChoose = viewModel::onRingtoneChosen, + playingUri = previewPlaying, onPreview = viewModel::onPreview, onPickFile = { pickDocument.launch(arrayOf(AUDIO_MIME)) }, onDismiss = viewModel::onCloseRingtonePicker, diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModel.kt index 47f055a..5d6f3ea 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModel.kt @@ -68,6 +68,9 @@ class AlarmEditorViewModel @Inject internal constructor( /** Null while the picker is closed. */ val ringtonePicker: StateFlow = _ringtonePicker.asStateFlow() + /** The sound the picker is auditioning, so its row can offer to stop it. */ + val previewPlaying: StateFlow = preview.playing + /** What the previewer is sounding, so the next preview knows to stop it. */ private var previewing: String? = null diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/RingtonePickerScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/RingtonePickerScreen.kt index e905563..630969d 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/RingtonePickerScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/RingtonePickerScreen.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material.icons.rounded.Stop import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -37,7 +38,8 @@ import de.jeanlucmakiola.floret.components.positionOf * * "Choose from files…" and "Choose from a music app…" are action rows rather * than options, so the option list stays a list of selectable states. Each - * unselected row can be auditioned through the play button; taking a row stops the preview and + * unselected row can be auditioned through the play button, which turns into a + * stop button while that row is sounding; taking a row stops the preview and * closes the picker (D21). * * The SAF launcher is **not** registered here: this screen only exists while @@ -49,6 +51,8 @@ import de.jeanlucmakiola.floret.components.positionOf internal fun RingtonePickerScreen( state: RingtonePickerUiState, defaultUri: String?, + /** What the previewer is sounding right now, or null. */ + playingUri: String?, onChoose: (String?) -> Unit, onPreview: (String?) -> Unit, onPickFile: () -> Unit, @@ -102,7 +106,7 @@ internal fun RingtonePickerScreen( ) } } - ringtoneOptions(state, rows, onChoose, onPreview, defaultUri, appDefaultLabel) + ringtoneOptions(state, rows, playingUri, onChoose, onPreview, defaultUri, appDefaultLabel) item { GroupedRow( title = stringResource(R.string.ringtone_pick_file), @@ -125,6 +129,7 @@ internal fun RingtonePickerScreen( private fun LazyListScope.ringtoneOptions( state: RingtonePickerUiState, rows: Int, + playingUri: String?, onChoose: (String?) -> Unit, onPreview: (String?) -> Unit, defaultUri: String?, @@ -139,6 +144,7 @@ private fun LazyListScope.ringtoneOptions( defaultUri = defaultUri, defaultTitle = state.defaultTitle, appDefaultLabel = appDefaultLabel, + playingUri = playingUri, onChoose = onChoose, onPreview = onPreview, ) @@ -160,6 +166,7 @@ private fun RingtoneOptionRow( defaultUri: String?, defaultTitle: String?, appDefaultLabel: String, + playingUri: String?, onChoose: (String?) -> Unit, onPreview: (String?) -> Unit, ) { @@ -204,6 +211,17 @@ private fun RingtoneOptionRow( selected -> { { SelectedCheck() } } + // Stop is onPreview(null): the ViewModels treat a null preview as stop. + previewUri != null && previewUri == playingUri -> { + { + IconButton(onClick = { onPreview(null) }) { + Icon( + Icons.Rounded.Stop, + contentDescription = stringResource(R.string.ringtone_preview_stop, title), + ) + } + } + } previewUri != null -> { { IconButton(onClick = { onPreview(previewUri) }) { diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsScreen.kt index c3900f2..e282032 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsScreen.kt @@ -83,6 +83,7 @@ fun SettingsScreen( ) { val state by viewModel.state.collectAsStateWithLifecycle() val ringtonePicker by viewModel.ringtonePicker.collectAsStateWithLifecycle() + val previewPlaying by viewModel.previewPlaying.collectAsStateWithLifecycle() val zonePicker by viewModel.zonePicker.collectAsStateWithLifecycle() var section by rememberSaveable { mutableStateOf(null) } @@ -134,6 +135,7 @@ fun SettingsScreen( defaultUri = null, appDefaultLabel = stringResource(R.string.ringtone_device_default), onChoose = viewModel::onRingtoneChosen, + playingUri = previewPlaying, onPreview = viewModel::onPreview, onPickFile = { pickDocument.launch(arrayOf(AUDIO_MIME)) }, onDismiss = viewModel::onCloseRingtonePicker, diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModel.kt index aeed719..75876f9 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModel.kt @@ -96,6 +96,9 @@ class SettingsViewModel @Inject internal constructor( internal val ringtonePicker: StateFlow?> = _ringtonePicker.asStateFlow() + /** The sound the picker is auditioning, so its row can offer to stop it. */ + val previewPlaying: StateFlow = preview.playing + /** What the previewer is sounding, so the next preview knows to stop it first. */ private var previewing: String? = null diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerEditorScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerEditorScreen.kt index f51273b..1f1e22a 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerEditorScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerEditorScreen.kt @@ -53,6 +53,7 @@ fun TimerEditorScreen( ) { val state by viewModel.state.collectAsStateWithLifecycle() val picker by viewModel.ringtonePicker.collectAsStateWithLifecycle() + val previewPlaying by viewModel.previewPlaying.collectAsStateWithLifecycle() var confirmingDelete by rememberSaveable { mutableStateOf(false) } // Registered unconditionally, and deliberately not inside the `picker?.let` @@ -109,6 +110,7 @@ fun TimerEditorScreen( state = pickerState, defaultUri = editing?.defaults?.timerRingtoneUri, onChoose = viewModel::onRingtoneChosen, + playingUri = previewPlaying, onPreview = viewModel::onPreview, onPickFile = { pickDocument.launch(arrayOf(AUDIO_MIME)) }, onDismiss = viewModel::onCloseRingtonePicker, diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerEditorViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerEditorViewModel.kt index 17532ca..038118e 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerEditorViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerEditorViewModel.kt @@ -67,6 +67,9 @@ class TimerEditorViewModel @Inject internal constructor( /** Null while closed. M5's type. */ val ringtonePicker: StateFlow = _ringtonePicker.asStateFlow() + /** The sound the picker is auditioning, so its row can offer to stop it. */ + val previewPlaying: StateFlow = preview.playing + /** What the previewer is sounding, so the next preview knows to stop it. */ private var previewing: String? = null diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a1fde1f..38e94b9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -169,6 +169,7 @@ Silent Vibration only Device default alarm sound + Stop %1$s Choose from files\u2026 This device has no alarm sounds. Clockula will use the system default. Unavailable \u2014 the default alarm sound will be used diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeRingtones.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeRingtones.kt index a7686ef..7cf1fe4 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeRingtones.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeRingtones.kt @@ -4,6 +4,7 @@ import de.jeanlucmakiola.clockula.data.ringtones.RingtoneCatalog import de.jeanlucmakiola.clockula.data.ringtones.RingtonePreviewer import de.jeanlucmakiola.clockula.domain.RingtoneOption import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.MutableStateFlow /** * The device's sound catalog, scripted: which tones it has, what it can name, @@ -68,11 +69,15 @@ class FakeRingtonePreviewer : RingtonePreviewer { val events: MutableList = mutableListOf() + override val playing = MutableStateFlow(null) + override suspend fun play(uri: String) { events += PreviewEvent.Played(uri) + playing.value = uri } override fun stop() { events += PreviewEvent.Stopped + playing.value = null } }