fix: fall back at once when a music app refuses the alarm

The start waited for PLAYING and nothing else, so an app that answered
the play request with an error (YouTube without Premium) left the alarm
vibrating in silence for the full 15 s START_TIMEOUT before the default
sound took over. An error stamped at or after the request, or a lost
connection, now ends the wait straight away. An error left over from
the user's own listening still doesn't count.
This commit is contained in:
makiolaj committed 2026-10-21 15:31:38 +02:00
1 parent 6637554c9d
commit d5016183ad
3 files changed
+36 -1

No files matched your search

@@ -60,6 +60,17 @@ object MediaRingPolicy {
fun shouldFallBack(stall: Stall): Boolean = stall.stopped >= STALL_GRACE || stall.notPlaying >= BUFFER_GRACE
/**
* Whether the app turned down the ring's play request, so the start can
* fall back now instead of waiting out [START_TIMEOUT]. YouTube without
* Premium does this at once. Only an error reported at or after
* [requestedAtMillis] counts: one left over from the user's own listening
* says nothing about this request. Both times are `elapsedRealtime`, which
* is what `PlaybackState` stamps its updates with.
*/
fun refusedStart(isError: Boolean, updatedAtMillis: Long, requestedAtMillis: Long): Boolean =
isError && updatedAtMillis >= requestedAtMillis
/**
* The media-stream index a fully ramped ring plays at.
*
@@ -6,6 +6,7 @@ import android.media.AudioManager
import android.media.browse.MediaBrowser
import android.media.session.MediaController
import android.media.session.PlaybackState
import android.os.SystemClock
import de.jeanlucmakiola.clockula.data.ringtones.connectMediaBrowser
import de.jeanlucmakiola.clockula.domain.MediaSourceRef
import de.jeanlucmakiola.clockula.domain.ring.MediaPlayback
@@ -86,6 +87,7 @@ internal class MediaAppRing(private val context: Context, private val ref: Media
}
}
if (stopped) return false
val requestedAt = SystemClock.elapsedRealtime()
runCatching {
if (ref.mediaId != null) controls.playFromMediaId(ref.mediaId, null) else controls.play()
}.onFailure { return false }
@@ -93,11 +95,23 @@ internal class MediaAppRing(private val context: Context, private val ref: Media
return withTimeoutOrNull(MediaRingPolicy.START_TIMEOUT) {
// Strictly PLAYING here: an app stuck buffering a stream it cannot
// reach must not count as a ring that started.
while (!stopped && playbackState() != PlaybackState.STATE_PLAYING) delay(POLL_MILLIS)
while (!stopped && playbackState() != PlaybackState.STATE_PLAYING) {
if (connectionLost || refused(requestedAt)) return@withTimeoutOrNull false
delay(POLL_MILLIS)
}
!stopped
} ?: false
}
private fun refused(requestedAt: Long): Boolean {
val state = runCatching { controller?.playbackState }.getOrNull() ?: return false
return MediaRingPolicy.refusedStart(
isError = state.state == PlaybackState.STATE_ERROR,
updatedAtMillis = state.lastPositionUpdateTime,
requestedAtMillis = requestedAt,
)
}
fun playback(): MediaPlayback {
if (connectionLost) return MediaPlayback.STOPPED
return when (playbackState()) {
@@ -29,6 +29,16 @@ class MediaRingPolicyTest {
assertThat(MediaRingPolicy.shouldFallBack(long)).isTrue()
}
@Test
fun `an error after the play request ends the start, an older one does not`() {
val requestedAt = 10_000L
assertThat(MediaRingPolicy.refusedStart(isError = true, updatedAtMillis = 10_050L, requestedAtMillis = requestedAt)).isTrue()
assertThat(MediaRingPolicy.refusedStart(isError = true, updatedAtMillis = requestedAt, requestedAtMillis = requestedAt)).isTrue()
assertThat(MediaRingPolicy.refusedStart(isError = true, updatedAtMillis = 9_000L, requestedAtMillis = requestedAt)).isFalse()
assertThat(MediaRingPolicy.refusedStart(isError = false, updatedAtMillis = 10_050L, requestedAtMillis = requestedAt)).isFalse()
}
@Test
fun `buffering forever is a failure too`() {
val brief = run(*repeat(MediaPlayback.TRANSITIONING, MediaRingPolicy.BUFFER_GRACE - tick))