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.
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
+13
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user