fix(ringtones): let a sound preview be stopped
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Failing after 10s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped

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.
This commit is contained in:
2026-10-02 22:33:20 +02:00
parent fe64521707
commit face2edb8d
11 changed files with 59 additions and 3 deletions
@@ -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<String?>
/** Plays [uri] once over USAGE_ALARM, stopping anything already playing. */
suspend fun play(uri: String)
@@ -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<String?>(null)
override val playing: StateFlow<String?> = _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? {
@@ -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,
@@ -68,6 +68,9 @@ class AlarmEditorViewModel @Inject internal constructor(
/** Null while the picker is closed. */
val ringtonePicker: StateFlow<RingtonePickerUiState?> = _ringtonePicker.asStateFlow()
/** The sound the picker is auditioning, so its row can offer to stop it. */
val previewPlaying: StateFlow<String?> = preview.playing
/** What the previewer is sounding, so the next preview knows to stop it. */
private var previewing: String? = null
@@ -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) }) {
@@ -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<SettingsSection?>(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,
@@ -96,6 +96,9 @@ class SettingsViewModel @Inject internal constructor(
internal val ringtonePicker: StateFlow<Pair<DefaultRingtoneKind, RingtonePickerUiState>?> =
_ringtonePicker.asStateFlow()
/** The sound the picker is auditioning, so its row can offer to stop it. */
val previewPlaying: StateFlow<String?> = preview.playing
/** What the previewer is sounding, so the next preview knows to stop it first. */
private var previewing: String? = null
@@ -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,
@@ -67,6 +67,9 @@ class TimerEditorViewModel @Inject internal constructor(
/** Null while closed. M5's type. */
val ringtonePicker: StateFlow<RingtonePickerUiState?> = _ringtonePicker.asStateFlow()
/** The sound the picker is auditioning, so its row can offer to stop it. */
val previewPlaying: StateFlow<String?> = preview.playing
/** What the previewer is sounding, so the next preview knows to stop it. */
private var previewing: String? = null
+1
View File
@@ -169,6 +169,7 @@
<string name="ringtone_silent">Silent</string>
<string name="ringtone_silent_summary">Vibration only</string>
<string name="ringtone_device_default">Device default alarm sound</string>
<string name="ringtone_preview_stop">Stop %1$s</string>
<string name="ringtone_pick_file">Choose from files\u2026</string>
<string name="ringtone_none_available">This device has no alarm sounds. Clockula will use the system default.</string>
<string name="ringtone_unavailable">Unavailable \u2014 the default alarm sound will be used</string>
@@ -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<PreviewEvent> = mutableListOf()
override val playing = MutableStateFlow<String?>(null)
override suspend fun play(uri: String) {
events += PreviewEvent.Played(uri)
playing.value = uri
}
override fun stop() {
events += PreviewEvent.Stopped
playing.value = null
}
}