Little playback fixes

This commit is contained in:
birdbird committed 2026-10-01 21:35:10 +00:00
1 parent 1ce70142a1
commit de326beaf2
3 files changed
+267 -22

No files matched your search

@@ -0,0 +1,108 @@
/*
* PlaybackErrorClassifier.kt
* Copyright (C) 2009-2026 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.service
import androidx.media3.common.PlaybackException
import java.io.EOFException
import java.io.FileNotFoundException
import java.net.ConnectException
import java.net.SocketException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import org.moire.ultrasonic.data.causeChain
/**
* Classifies a Media3 playback error into the recovery categories used by
* [PlaybackService.recoverFromPlaybackError].
*
* Media3 halts in [androidx.media3.common.Player.STATE_IDLE] after any unhandled item
* error and never advances on its own, so an unclassified error leaves playback stopped
* with a dead UI. Anything that is scoped to a single item is recoverable: it either
* deserves a same-item retry (the network may already have recovered) or a skip to the
* next item (the item itself is unplayable, but the rest of the queue is fine). Only
* errors that do not point at an item are left unhandled, so an unexpected failure
* surfaces to the user instead of looping through the queue.
*/
internal object PlaybackErrorClassifier {
enum class Category {
/**
* Transient transport failure (socket timeout, connection refused, DNS): the
* item is probably fine, retry it before giving up on it.
*/
TRANSIENT_CONNECTIVITY,
/**
* The library is offline ([OfflineException] reached the loader for a queued
* streaming track): the item cannot play now, but remains queued for later.
*/
OFFLINE,
/**
* The item itself is unplayable (HTTP error status, malformed container,
* undecodable format, missing or truncated file): re-preparing cannot succeed,
* advance to the next item in play order.
*/
ITEM_LEVEL,
/**
* Anything that does not point at a single item (remote errors, live window,
* unexpected runtime checks): no automatic recovery, the error is surfaced
* through the player state.
*/
UNEXPECTED
}
fun classify(error: PlaybackException): Category = when {
error.hasTransientConnectivityFailure() -> Category.TRANSIENT_CONNECTIVITY
error.hasOfflineCause() -> Category.OFFLINE
error.hasItemLevelCause() -> Category.ITEM_LEVEL
else -> Category.UNEXPECTED
}
private fun PlaybackException.hasTransientConnectivityFailure(): Boolean =
errorCode == PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED ||
errorCode == PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT ||
causeChain().any {
it is SocketTimeoutException ||
it is ConnectException ||
it is SocketException ||
it is UnknownHostException
}
private fun PlaybackException.hasOfflineCause(): Boolean =
causeChain().any { it is OfflineException }
private fun PlaybackException.hasItemLevelCause(): Boolean =
errorCode in ITEM_LEVEL_ERROR_CODES ||
causeChain().any { it is FileNotFoundException || it is EOFException }
// Item-scoped Media3 error codes: an I/O failure with the item's source (HTTP
// status, missing or unreadable file, wrong content type), a failure to parse the
// item's container or manifest, and a failure to decode or render the item's
// streams. Cleartext not permitted is deliberately excluded: it is a configuration
// problem that affects every stream, not a property of one item.
private val ITEM_LEVEL_ERROR_CODES = setOf(
PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS,
PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND,
PlaybackException.ERROR_CODE_IO_NO_PERMISSION,
PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE,
PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE,
PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED,
PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED,
PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED,
PlaybackException.ERROR_CODE_PARSING_MANIFEST_UNSUPPORTED,
PlaybackException.ERROR_CODE_DECODER_INIT_FAILED,
PlaybackException.ERROR_CODE_DECODER_QUERY_FAILED,
PlaybackException.ERROR_CODE_DECODING_FAILED,
PlaybackException.ERROR_CODE_DECODING_FORMAT_EXCEEDS_CAPABILITIES,
PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED,
PlaybackException.ERROR_CODE_DECODING_RESOURCES_RECLAIMED,
PlaybackException.ERROR_CODE_AUDIO_TRACK_INIT_FAILED,
PlaybackException.ERROR_CODE_AUDIO_TRACK_WRITE_FAILED
)
}
@@ -36,10 +36,6 @@ import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.session.MediaLibraryService
import androidx.media3.session.MediaSession
import java.net.ConnectException
import java.net.SocketException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -60,7 +56,6 @@ import org.moire.ultrasonic.audiofx.EqualizerController
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.data.CachedDataSource
import org.moire.ultrasonic.data.LibraryAccessState
import org.moire.ultrasonic.data.causeChain
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.imageloader.ArtworkBitmapLoader
import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider
@@ -120,6 +115,16 @@ class PlaybackService :
override fun onCreate() {
Timber.i("onCreate called")
super.onCreate()
// Keep the media notification (and with it the foreground service) whenever the
// queue is non-empty, even while the player sits in STATE_IDLE. The media3
// default (AFTER_STOP_OR_ERROR) re-hides the notification for a queue that was
// restored but not prepared yet, so a service restart after a fatal error could
// leave playback dead with no notification and no way back into the player.
// A visible notification for a stopped player lets the user resume or dismiss
// it instead of silently losing playback (issue 1313).
setShowNotificationForIdlePlayer(SHOW_NOTIFICATION_FOR_IDLE_PLAYER_ALWAYS)
initializeSessionAndPlayer()
setListener(MediaSessionServiceListener())
}
@@ -548,8 +553,9 @@ class PlaybackService :
/**
* Recovers from a failed playback item without disturbing the queue. A transient
* connectivity failure gets a bounded number of same-item retries, other recoverable
* failures (e.g. OfflineException for a queued streaming track while offline) advance
* to the next item in play order. The queue itself is never edited here: entries
* failures (e.g. OfflineException for a queued streaming track while offline, or an
* item-level error such as a bad HTTP status or an undecodable file) advance to the
* next item in play order. The queue itself is never edited here: entries
* survive so playback continues wherever material is available, and the original
* queue is intact once the server is reachable again.
*
@@ -558,8 +564,10 @@ class PlaybackService :
* offline mode.
*/
private fun recoverFromPlaybackError(error: PlaybackException) {
val connectivity = error.hasTransientConnectivityFailure()
if (!connectivity && !error.hasOfflineCause()) return
val category = PlaybackErrorClassifier.classify(error)
if (category == PlaybackErrorClassifier.Category.UNEXPECTED) return
val connectivity = category == PlaybackErrorClassifier.Category.TRANSIENT_CONNECTIVITY
when (val recovery = failureTracker.onItemFailure(retryable = connectivity)) {
is PlaybackFailureTracker.Recovery.RetrySameItem ->
@@ -621,19 +629,6 @@ class PlaybackService :
pendingRecovery = null
}
private fun PlaybackException.hasTransientConnectivityFailure(): Boolean =
errorCode == PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED ||
errorCode == PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT ||
causeChain().any {
it is SocketTimeoutException ||
it is ConnectException ||
it is SocketException ||
it is UnknownHostException
}
private fun PlaybackException.hasOfflineCause(): Boolean =
causeChain().any { it is OfflineException }
private val listener: Player.Listener = object : Player.Listener {
override fun onPlayerError(error: PlaybackException) {
Timber.w(error, "Playback error")
@@ -0,0 +1,142 @@
/*
* PlaybackErrorClassifierTest.kt
* Copyright (C) 2009-2026 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.service
import androidx.media3.common.PlaybackException
import java.io.EOFException
import java.io.FileNotFoundException
import java.io.IOException
import java.net.SocketTimeoutException
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class PlaybackErrorClassifierTest {
private fun error(cause: Throwable?, code: Int) = PlaybackException("test", cause, code)
@Test
fun `network connection failure is transient connectivity`() {
val category = PlaybackErrorClassifier.classify(
error(null, PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED)
)
category shouldBeEqualTo PlaybackErrorClassifier.Category.TRANSIENT_CONNECTIVITY
}
@Test
fun `socket timeout cause is transient connectivity`() {
val category = PlaybackErrorClassifier.classify(
error(
IOException(SocketTimeoutException("read timed out")),
PlaybackException.ERROR_CODE_IO_UNSPECIFIED
)
)
category shouldBeEqualTo PlaybackErrorClassifier.Category.TRANSIENT_CONNECTIVITY
}
@Test
fun `offline exception is offline`() {
val category = PlaybackErrorClassifier.classify(
error(OfflineException("offline"), PlaybackException.ERROR_CODE_IO_UNSPECIFIED)
)
category shouldBeEqualTo PlaybackErrorClassifier.Category.OFFLINE
}
@Test
fun `bad http status is item level`() {
val category = PlaybackErrorClassifier.classify(
error(null, PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS)
)
category shouldBeEqualTo PlaybackErrorClassifier.Category.ITEM_LEVEL
}
@Test
fun `malformed container is item level`() {
val category = PlaybackErrorClassifier.classify(
error(null, PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED)
)
category shouldBeEqualTo PlaybackErrorClassifier.Category.ITEM_LEVEL
}
@Test
fun `decoding failure is item level`() {
val category = PlaybackErrorClassifier.classify(
error(null, PlaybackException.ERROR_CODE_DECODING_FAILED)
)
category shouldBeEqualTo PlaybackErrorClassifier.Category.ITEM_LEVEL
}
@Test
fun `missing file cause is item level`() {
val category = PlaybackErrorClassifier.classify(
error(
IOException(FileNotFoundException("cache file pruned")),
PlaybackException.ERROR_CODE_IO_UNSPECIFIED
)
)
category shouldBeEqualTo PlaybackErrorClassifier.Category.ITEM_LEVEL
}
@Test
fun `truncated file cause is item level`() {
val category = PlaybackErrorClassifier.classify(
error(
EOFException("unexpected end of file"),
PlaybackException.ERROR_CODE_IO_UNSPECIFIED
)
)
category shouldBeEqualTo PlaybackErrorClassifier.Category.ITEM_LEVEL
}
@Test
fun `unspecified error without a known cause is unexpected`() {
val category = PlaybackErrorClassifier.classify(
error(IllegalStateException("boom"), PlaybackException.ERROR_CODE_UNSPECIFIED)
)
category shouldBeEqualTo PlaybackErrorClassifier.Category.UNEXPECTED
}
@Test
fun `cleartext not permitted is unexpected because it affects every stream`() {
val category = PlaybackErrorClassifier.classify(
error(null, PlaybackException.ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED)
)
category shouldBeEqualTo PlaybackErrorClassifier.Category.UNEXPECTED
}
@Test
fun `remote error is unexpected`() {
val category = PlaybackErrorClassifier.classify(
error(null, PlaybackException.ERROR_CODE_REMOTE_ERROR)
)
category shouldBeEqualTo PlaybackErrorClassifier.Category.UNEXPECTED
}
@Test
fun `connectivity classification wins over other causes`() {
val cause = IOException(SocketTimeoutException("timed out"))
val category = PlaybackErrorClassifier.classify(
error(cause, PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS)
)
category shouldBeEqualTo PlaybackErrorClassifier.Category.TRANSIENT_CONNECTIVITY
}
}