fix(player): exit with an audio-output error when the Android audio device stalls

On a Fire TV whose audio HAL stopped taking audio (#2255 investigation),
mpv playback froze silently: the AudioTrack writer blocked forever and
nothing surfaced. The native side now gives up after one bounded recovery
and ends the file with MPV_ERROR_AO_INIT_FAILED; this carries that verdict
to the UI.

The JNI bridge forwards mpv_event_end_file.error (previously discarded),
MpvEvent.EndFile exposes it as a typed MpvError, and
MpvEndFileDiagnostics tags an AO failure with the audio-output-failed
cause. The Dart failure policy treats that cause as terminal before any
HTTP diagnosis or the live-TV ladder, since re-opening the stream would
only run the same dead output again, and the player shows localized
guidance instead of raw log text. Progress is preserved; nothing is
marked completed.

Requires the mpv-build revision carrying the audiotrack stall watchdog;
until the lock is bumped, the new cause can only come from audio-only
media whose output fails to open.
This commit is contained in:
edde746
2026-09-08 02:27:10 +02:00
parent c6c0ce8bea
commit d7f1d4a8fc
35 changed files with 144 additions and 13 deletions
@@ -3,12 +3,22 @@ package com.edde746.plezy.mpv
import com.edde746.plezy.libmpv.EndFileReason
import com.edde746.plezy.libmpv.LogLevel
import com.edde746.plezy.libmpv.LogMessage
import com.edde746.plezy.libmpv.MpvError
import com.edde746.plezy.libmpv.MpvEvent
/** Adds the native diagnostic that libmpv-android exposes separately via logFlow. */
internal class MpvEndFileDiagnostics {
private var errorMessage: String? = null
companion object {
/**
* The audio device stopped taking audio (or never could) and mpv gave up
* on it. Keep in sync with PlayerError.audioOutputFailed in Dart: a device
* fault, so no stream retry or backend switch can recover it.
*/
const val CAUSE_AUDIO_OUTPUT_FAILED = "audio-output-failed"
}
fun onStartFile() {
errorMessage = null
}
@@ -26,6 +36,7 @@ internal class MpvEndFileDiagnostics {
data["reason"] = reason.id
if (reason == EndFileReason.Error) {
errorMessage?.let { data["message"] = it }
if (event.error == MpvError.AoInitFailed) data["cause"] = CAUSE_AUDIO_OUTPUT_FAILED
}
}
errorMessage = null
@@ -0,0 +1,43 @@
package com.edde746.plezy.mpv
import com.edde746.plezy.libmpv.EndFileReason
import com.edde746.plezy.libmpv.LogLevel
import com.edde746.plezy.libmpv.LogMessage
import com.edde746.plezy.libmpv.MpvError
import com.edde746.plezy.libmpv.MpvEvent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class MpvEndFileDiagnosticsTest {
private val diagnostics = MpvEndFileDiagnostics()
@Test
fun `an AO failure ends the file with the audio-output cause and the last error line`() {
diagnostics.onStartFile()
diagnostics.onLogMessage(LogMessage("cplayer", LogLevel.Error, "Audio output stopped responding; stopping playback."))
val data = diagnostics.onEndFile(MpvEvent.EndFile(EndFileReason.Error, 7, MpvError.AoInitFailed))
assertEquals(
mapOf(
"sourceId" to 7L,
"reason" to EndFileReason.Error.id,
"message" to "Audio output stopped responding; stopping playback.",
"cause" to MpvEndFileDiagnostics.CAUSE_AUDIO_OUTPUT_FAILED
),
data
)
}
@Test
fun `other errors carry no cause so Dart keeps its generic handling`() {
val data = diagnostics.onEndFile(MpvEvent.EndFile(EndFileReason.Error, 7, MpvError.LoadingFailed))
assertNull(data?.get("cause"))
}
@Test
fun `a clean stop never reports a cause even after an earlier error line`() {
diagnostics.onLogMessage(LogMessage("ao/audiotrack", LogLevel.Error, "AudioTrack.write failed with -32"))
val data = diagnostics.onEndFile(MpvEvent.EndFile(EndFileReason.Stop, 7))
assertEquals(mapOf("sourceId" to 7L, "reason" to EndFileReason.Stop.id), data)
}
}
+3 -1
View File
@@ -71,9 +71,11 @@ static void sendEndFileToJava(JNIEnv* env, mpv_event* event) {
mpv_event_end_file* end_file = (mpv_event_end_file*)event->data;
const int reason = end_file ? end_file->reason : -1;
const int64_t source_id = end_file ? end_file->playlist_entry_id : 0;
// mpv_error code when reason is MPV_END_FILE_REASON_ERROR, 0 otherwise.
const int error = end_file ? end_file->error : 0;
env->CallStaticVoidMethod(
mpv_MpvPlayer, mpv_MpvPlayer_onEndFile, (jlong)thread_session, (jint)reason, (jlong)source_id,
end_file ? JNI_TRUE : JNI_FALSE);
end_file ? JNI_TRUE : JNI_FALSE, (jint)error);
}
static void sendLogMessageToJava(JNIEnv* env, mpv_event_log_message* msg) {
+1 -1
View File
@@ -62,7 +62,7 @@ void init_methods_cache(JNIEnv* env) {
mpv_MpvPlayer_onPropertyChanged_SSJZ =
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(JLjava/lang/String;Ljava/lang/String;JZ)V");
mpv_MpvPlayer_onEvent = env->GetStaticMethodID(mpv_MpvPlayer, "onEvent", "(JIJZDZ)V");
mpv_MpvPlayer_onEndFile = env->GetStaticMethodID(mpv_MpvPlayer, "onEndFile", "(JIJZ)V");
mpv_MpvPlayer_onEndFile = env->GetStaticMethodID(mpv_MpvPlayer, "onEndFile", "(JIJZI)V");
mpv_MpvPlayer_onLogMessage =
env->GetStaticMethodID(mpv_MpvPlayer, "onLogMessage", "(JLjava/lang/String;ILjava/lang/String;)V");
mpv_MpvPlayer_onHook = env->GetStaticMethodID(mpv_MpvPlayer, "onHook", "(JLjava/lang/String;J)V");
@@ -0,0 +1,18 @@
package com.edde746.plezy.libmpv
/** The `mpv_error` codes an end-file event can carry (client.h). */
enum class MpvError(val code: Int) {
LoadingFailed(-13),
AoInitFailed(-14),
VoInitFailed(-15),
NothingToPlay(-16),
UnknownFormat(-17),
Unsupported(-18),
NotImplemented(-19),
Generic(-20);
companion object {
/** Null for success (0) and for codes this enum does not model. */
fun fromCode(code: Int): MpvError? = entries.find { it.code == code }
}
}
@@ -6,7 +6,9 @@ sealed interface MpvEvent {
data class StartFile(override val sourceId: Long?) : MpvEvent
data class EndFile(
val reason: EndFileReason?,
override val sourceId: Long?
override val sourceId: Long?,
/** mpv_error code when [reason] is [EndFileReason.Error]; null otherwise. */
val error: MpvError? = null
) : MpvEvent
data class FileLoaded(override val sourceId: Long?) : MpvEvent
data class PlaybackRestart(
@@ -140,11 +140,12 @@ class MpvPlayer private constructor(
}
@JvmStatic
fun onEndFile(session: Long, reason: Int, sourceId: Long, hasSourceId: Boolean) {
fun onEndFile(session: Long, reason: Int, sourceId: Long, hasSourceId: Boolean, error: Int) {
target(session)?.rawEvents?.trySend(
MpvEvent.EndFile(
EndFileReason.fromId(reason),
sourceId.takeIf { hasSourceId }
sourceId.takeIf { hasSourceId },
MpvError.fromCode(error)
)
)
}
+1
View File
@@ -779,6 +779,7 @@
"playbackDataInvalid": "Server yanlış oynatma məlumatı qaytardı.",
"playbackCancelled": "Oynatma ləğv edildi.",
"playbackFailed": "Oynatma başladılarkən xəta.",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Fayl məlumatı yüklənərkən xəta: ${error}",
"errorLoadingSeries": "Serial yüklənərkən xəta",
+1
View File
@@ -779,6 +779,7 @@
"playbackDataInvalid": "Сървърът върна невалидна информация за възпроизвеждането.",
"playbackCancelled": "Възпроизвеждането беше отменено.",
"playbackFailed": "Възпроизвеждането не можа да бъде стартирано.",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Грешка при зареждане на информация за файла: ${error}",
"errorLoadingSeries": "Грешка при зареждане на сериала",
+1
View File
@@ -779,6 +779,7 @@
"playbackDataInvalid": "Serveren returnerede ugyldige afspilningsoplysninger.",
"playbackCancelled": "Afspilningen blev annulleret.",
"playbackFailed": "Afspilningen kunne ikke startes.",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Fejl ved indlæsning af filinfo: ${error}",
"errorLoadingSeries": "Fejl ved indlæsning af serie",
+1
View File
@@ -779,6 +779,7 @@
"playbackDataInvalid": "Der Server hat ungültige Wiedergabeinformationen zurückgegeben.",
"playbackCancelled": "Die Wiedergabe wurde abgebrochen.",
"playbackFailed": "Die Wiedergabe konnte nicht gestartet werden.",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Fehler beim Laden der Dateiinfo: ${error}",
"errorLoadingSeries": "Fehler beim Laden der Serie",
+1
View File
@@ -779,6 +779,7 @@
"playbackDataInvalid": "The server returned invalid playback information.",
"playbackCancelled": "Playback was canceled.",
"playbackFailed": "Playback could not be started.",
"audioOutputFailed": "The audio output stopped responding. Check the TV or receiver's audio connection; if other apps have no sound either, restart the device.",
"mediaUnavailable": "This content is no longer available.",
"errorLoadingFileInfo": "Error loading file info: ${error}",
"errorLoadingSeries": "Error loading series",
+1
View File
@@ -779,6 +779,7 @@
"playbackDataInvalid": "El servidor devolvió información de reproducción no válida.",
"playbackCancelled": "Se canceló la reproducción.",
"playbackFailed": "No se pudo iniciar la reproducción.",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Error al cargar la información del archivo: ${error}",
"errorLoadingSeries": "Error al cargar la serie",
+1
View File
@@ -779,6 +779,7 @@
"playbackDataInvalid": "Le serveur a renvoyé des informations de lecture non valides.",
"playbackCancelled": "La lecture a été annulée.",
"playbackFailed": "Impossible de démarrer la lecture.",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Erreur lors du chargement des informations sur le fichier : ${error}",
"errorLoadingSeries": "Erreur lors du chargement de la série",
+1
View File
@@ -779,6 +779,7 @@
"playbackDataInvalid": "A szerver érvénytelen lejátszási adatokat küldött.",
"playbackCancelled": "A lejátszás megszakítva.",
"playbackFailed": "Nem sikerült elindítani a lejátszást.",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Hiba a fájlinformációk betöltésekor: ${error}",
"errorLoadingSeries": "Hiba a sorozat betöltésekor",
+1
View File
@@ -779,6 +779,7 @@
"playbackDataInvalid": "Il server ha restituito informazioni di riproduzione non valide.",
"playbackCancelled": "Riproduzione annullata.",
"playbackFailed": "Impossibile avviare la riproduzione.",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Errore durante il caricamento delle informazioni sul file: ${error}",
"errorLoadingSeries": "Errore durante il caricamento della serie",
+1
View File
@@ -775,6 +775,7 @@
"playbackDataInvalid": "サーバーから無効な再生情報が返されました。",
"playbackCancelled": "再生がキャンセルされました。",
"playbackFailed": "再生を開始できませんでした。",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "ファイル情報の読み込みエラー: ${error}",
"errorLoadingSeries": "シリーズの読み込みエラー",
+1
View File
@@ -779,6 +779,7 @@
"playbackDataInvalid": "Сервер қате ойнату мәліметтерін қайтарды.",
"playbackCancelled": "Ойнатудан бас тартылды.",
"playbackFailed": "Ойнатуды іске қосу қатесі.",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Файл ақпаратын жүктеу қатесі: ${error}",
"errorLoadingSeries": "Сериалды жүктеу қатесі",
+1
View File
@@ -775,6 +775,7 @@
"playbackDataInvalid": "서버에서 잘못된 재생 정보를 반환했습니다.",
"playbackCancelled": "재생이 취소되었습니다.",
"playbackFailed": "재생을 시작할 수 없습니다.",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "파일 정보 로딩 중 오류: ${error}",
"errorLoadingSeries": "시리즈 로딩 중 오류",
+1
View File
@@ -779,6 +779,7 @@
"playbackDataInvalid": "Serveren returnerte ugyldig avspillingsinformasjon.",
"playbackCancelled": "Avspillingen ble avbrutt.",
"playbackFailed": "Kunne ikke starte avspillingen.",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Feil ved lasting av filinformasjon: ${error}",
"errorLoadingSeries": "Feil ved lasting av serie",
+1
View File
@@ -779,6 +779,7 @@
"playbackDataInvalid": "De server heeft ongeldige afspeelinformatie geretourneerd.",
"playbackCancelled": "Het afspelen is geannuleerd.",
"playbackFailed": "Het afspelen kon niet worden gestart.",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Fout bij laden van bestandsinformatie: ${error}",
"errorLoadingSeries": "Fout bij laden van serie",
+1
View File
@@ -787,6 +787,7 @@
"playbackDataInvalid": "Serwer zwrócił nieprawidłowe informacje o odtwarzaniu.",
"playbackCancelled": "Odtwarzanie zostało anulowane.",
"playbackFailed": "Nie udało się rozpocząć odtwarzania.",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Błąd ładowania informacji o pliku: ${error}",
"errorLoadingSeries": "Błąd ładowania serialu",
+1
View File
@@ -779,6 +779,7 @@
"playbackDataInvalid": "O servidor retornou informações de reprodução inválidas.",
"playbackCancelled": "A reprodução foi cancelada.",
"playbackFailed": "Não foi possível iniciar a reprodução.",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Erro ao carregar as informações do arquivo: ${error}",
"errorLoadingSeries": "Erro ao carregar série",
+1
View File
@@ -787,6 +787,7 @@
"playbackDataInvalid": "Сервер вернул некорректные сведения для воспроизведения.",
"playbackCancelled": "Воспроизведение отменено.",
"playbackFailed": "Не удалось начать воспроизведение.",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Ошибка загрузки информации о файле: ${error}",
"errorLoadingSeries": "Ошибка загрузки сериала",
+1 -1
View File
@@ -4,7 +4,7 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 22
/// Strings: 43687 (1985 per locale)
/// Strings: 43688 (1985 per locale)
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import
+6 -2
View File
@@ -2272,6 +2272,9 @@ class Translations$messages$en {
/// en: 'Playback could not be started.'
String get playbackFailed => 'Playback could not be started.';
/// en: 'The audio output stopped responding. Check the TV or receiver's audio connection; if other apps have no sound either, restart the device.'
String get audioOutputFailed => 'The audio output stopped responding. Check the TV or receiver\'s audio connection; if other apps have no sound either, restart the device.';
/// en: 'This content is no longer available.'
String get mediaUnavailable => 'This content is no longer available.';
@@ -7749,6 +7752,7 @@ extension on Translations {
'messages.playbackDataInvalid' => 'The server returned invalid playback information.',
'messages.playbackCancelled' => 'Playback was canceled.',
'messages.playbackFailed' => 'Playback could not be started.',
'messages.audioOutputFailed' => 'The audio output stopped responding. Check the TV or receiver\'s audio connection; if other apps have no sound either, restart the device.',
'messages.mediaUnavailable' => 'This content is no longer available.',
'messages.errorLoadingFileInfo' => ({required Object error}) => 'Error loading file info: ${error}',
'messages.errorLoadingSeries' => 'Error loading series',
@@ -8044,9 +8048,9 @@ extension on Translations {
'libraries.noFoldersFound' => 'No folders found',
'libraries.folders' => 'folders',
'libraries.tabs.recommended' => 'Recommended',
'libraries.tabs.browse' => 'Browse',
_ => null,
} ?? switch (path) {
'libraries.tabs.browse' => 'Browse',
'libraries.tabs.collections' => 'Collections',
'libraries.tabs.playlists' => 'Playlists',
'libraries.groupings.title' => 'Grouping',
@@ -8558,9 +8562,9 @@ extension on Translations {
'downloads.downloadingTooltip' => 'Downloading...',
'downloads.downloadingFilesTooltip' => ({required Object files}) => 'Downloading ${files}',
'downloads.noDownloadsTree' => 'No downloads',
'downloads.pauseAll' => 'Pause all',
_ => null,
} ?? switch (path) {
'downloads.pauseAll' => 'Pause all',
'downloads.resumeAll' => 'Resume all',
'downloads.deleteAll' => 'Delete all',
'downloads.selectVersion' => 'Select Version',
+1
View File
@@ -779,6 +779,7 @@
"playbackDataInvalid": "Servern returnerade ogiltig uppspelningsinformation.",
"playbackCancelled": "Uppspelningen avbröts.",
"playbackFailed": "Det gick inte att starta uppspelningen.",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Fel vid laddning av filinformation: ${error}",
"errorLoadingSeries": "Fel vid laddning av serie",
+1
View File
@@ -779,6 +779,7 @@
"playbackDataInvalid": "Sunucu geçersiz oynatma bilgisi döndürdü.",
"playbackCancelled": "Oynatma iptal edildi.",
"playbackFailed": "Oynatma başlatılamadı.",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Dosya bilgisi yüklenirken hata: ${error}",
"errorLoadingSeries": "Dizi yüklenirken hata",
+1
View File
@@ -779,6 +779,7 @@
"playbackDataInvalid": "Server notoʻgʻri ijro maʼlumotlarini qaytardi.",
"playbackCancelled": "Ijro bekor qilindi.",
"playbackFailed": "Ijroni ishga tushirishda xatolik.",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Fayl maʼlumotlarini yuklashda xatolik: ${error}",
"errorLoadingSeries": "Serialni yuklashda xatolik",
+1
View File
@@ -775,6 +775,7 @@
"playbackDataInvalid": "伺服器傳回的播放資訊無效。",
"playbackCancelled": "播放已取消。",
"playbackFailed": "無法開始播放。",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "載入檔案資訊時發生錯誤:${error}",
"errorLoadingSeries": "載入影集時發生錯誤",
+1
View File
@@ -775,6 +775,7 @@
"playbackDataInvalid": "服务器返回了无效的播放信息。",
"playbackCancelled": "播放已取消。",
"playbackFailed": "无法开始播放。",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "加载文件信息时出错:${error}",
"errorLoadingSeries": "加载剧集时出错",
+7
View File
@@ -55,6 +55,13 @@ sealed class PlayerError with _$PlayerError {
/// copy from this tag instead of parsing it.
static const String playerInitFailed = 'player-init-failed';
/// Cause tag for an audio device that stopped taking audio (or never
/// could) after the native core's own bounded recovery. A device fault, not
/// a stream fault: no stream retry, quality change, or backend switch can
/// recover it, so it is terminal on live TV too. Keep in sync with
/// MpvEndFileDiagnostics.CAUSE_AUDIO_OUTPUT_FAILED on Android.
static const String audioOutputFailed = 'audio-output-failed';
/// HTTP status [logText] reports, or null when it names none.
///
/// A [PlayerError] carries no status field: mpv only ever tells us the
+5 -5
View File
@@ -60,11 +60,11 @@ extension _VideoPlayerErrorMethods on VideoPlayerScreenState {
// A failed core start carries only diagnostic text; _lastLogError is
// raw mpv/ffmpeg output, so neither is fit to show — use the
// localized copy instead.
showGlobalErrorSnackBar(
err.cause == PlayerError.playerInitFailed
? t.messages.playbackFailed
: _redactPlayerError(_lastLogError ?? err.message),
);
showGlobalErrorSnackBar(switch (err.cause) {
PlayerError.playerInitFailed => t.messages.playbackFailed,
PlayerError.audioOutputFailed => t.messages.audioOutputFailed,
_ => _redactPlayerError(_lastLogError ?? err.message),
});
unawaited(_handleBackButton());
}
}
@@ -41,6 +41,10 @@ enum PlaybackFailureAction {
/// arrives only as the open-phase watchdog's cause tag (it never latches into
/// [fatalHttpStatuses]); by then the reconnect loop has had its chances, so
/// on-demand playback surfaces it while live TV keeps its ladder.
///
/// An audio-output failure is checked first: the device stopped taking audio,
/// so a latched status or the live ladder would only re-open a stream into the
/// same dead output.
PlaybackFailureAction resolvePlaybackFailureAction({
required String? cause,
required Set<int> fatalHttpStatuses,
@@ -49,6 +53,8 @@ PlaybackFailureAction resolvePlaybackFailureAction({
required int liveFallbackLevel,
required bool liveRetryFailed,
}) {
if (cause == PlayerError.audioOutputFailed) return PlaybackFailureAction.fatal;
if (cause == PlayerError.serverHttp500 || fatalHttpStatuses.contains(500)) {
return PlaybackFailureAction.serverLimitDialog;
}
@@ -79,6 +79,21 @@ void main() {
});
});
group('audio output failure', () {
test('is terminal even where a live retry or latched status would apply', () {
// The device stopped taking audio (#2255 Fire TV): re-opening the stream
// on the live ladder or diagnosing a latched status would only run the
// same dead output again.
expect(resolve(cause: PlayerError.audioOutputFailed), PlaybackFailureAction.fatal);
expect(resolve(cause: PlayerError.audioOutputFailed, isLive: true), PlaybackFailureAction.fatal);
expect(
resolve(cause: PlayerError.audioOutputFailed, isLive: true, liveRetrying: true),
PlaybackFailureAction.fatal,
);
expect(resolve(cause: PlayerError.audioOutputFailed, statuses: {404}), PlaybackFailureAction.fatal);
});
});
group('live fallback ladder', () {
test('climbs every rung below the bound', () {
for (var level = 0; level < maxLiveFallbackLevel; level++) {