mirror of
https://gitlab.com/ultrasonic/ultrasonic.git
synced 2026-10-22 23:42:14 +03:00
Little playback fixes
This commit is contained in:
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")
|
||||
|
||||
+142
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user