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 } }