From d7f1d4a8fc3f6edbc549f45e7124bf7d3e9750b7 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Tue, 8 Sep 2026 02:08:49 +0200 Subject: [PATCH] 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. --- .../plezy/mpv/MpvEndFileDiagnostics.kt | 11 +++++ .../plezy/mpv/MpvEndFileDiagnosticsTest.kt | 43 +++++++++++++++++++ android/libmpv/src/main/cpp/event.cpp | 4 +- android/libmpv/src/main/cpp/jni_utils.cpp | 2 +- .../java/com/edde746/plezy/libmpv/MpvError.kt | 18 ++++++++ .../java/com/edde746/plezy/libmpv/MpvEvent.kt | 4 +- .../com/edde746/plezy/libmpv/MpvPlayer.kt | 5 ++- lib/i18n/az.i18n.json | 1 + lib/i18n/bg.i18n.json | 1 + lib/i18n/da.i18n.json | 1 + lib/i18n/de.i18n.json | 1 + lib/i18n/en.i18n.json | 1 + lib/i18n/es.i18n.json | 1 + lib/i18n/fr.i18n.json | 1 + lib/i18n/hu.i18n.json | 1 + lib/i18n/it.i18n.json | 1 + lib/i18n/ja.i18n.json | 1 + lib/i18n/kk.i18n.json | 1 + lib/i18n/ko.i18n.json | 1 + lib/i18n/nb.i18n.json | 1 + lib/i18n/nl.i18n.json | 1 + lib/i18n/pl.i18n.json | 1 + lib/i18n/pt.i18n.json | 1 + lib/i18n/ru.i18n.json | 1 + lib/i18n/strings.g.dart | 2 +- lib/i18n/strings_en.g.dart | 8 +++- lib/i18n/sv.i18n.json | 1 + lib/i18n/tr.i18n.json | 1 + lib/i18n/uz.i18n.json | 1 + lib/i18n/zh-Hant.i18n.json | 1 + lib/i18n/zh.i18n.json | 1 + lib/mpv/models.dart | 7 +++ lib/screens/video_player/parts/errors.dart | 10 ++--- .../video_player/playback_failure_action.dart | 6 +++ .../playback_failure_action_test.dart | 15 +++++++ 35 files changed, 144 insertions(+), 13 deletions(-) create mode 100644 android/app/src/test/kotlin/com/edde746/plezy/mpv/MpvEndFileDiagnosticsTest.kt create mode 100644 android/libmpv/src/main/java/com/edde746/plezy/libmpv/MpvError.kt diff --git a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvEndFileDiagnostics.kt b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvEndFileDiagnostics.kt index 6742aa46b..15ff83b7b 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvEndFileDiagnostics.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvEndFileDiagnostics.kt @@ -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 diff --git a/android/app/src/test/kotlin/com/edde746/plezy/mpv/MpvEndFileDiagnosticsTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/mpv/MpvEndFileDiagnosticsTest.kt new file mode 100644 index 000000000..041e80031 --- /dev/null +++ b/android/app/src/test/kotlin/com/edde746/plezy/mpv/MpvEndFileDiagnosticsTest.kt @@ -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) + } +} diff --git a/android/libmpv/src/main/cpp/event.cpp b/android/libmpv/src/main/cpp/event.cpp index adf14292d..c0bd071f3 100644 --- a/android/libmpv/src/main/cpp/event.cpp +++ b/android/libmpv/src/main/cpp/event.cpp @@ -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) { diff --git a/android/libmpv/src/main/cpp/jni_utils.cpp b/android/libmpv/src/main/cpp/jni_utils.cpp index 3bf828767..2f2802d4d 100644 --- a/android/libmpv/src/main/cpp/jni_utils.cpp +++ b/android/libmpv/src/main/cpp/jni_utils.cpp @@ -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"); diff --git a/android/libmpv/src/main/java/com/edde746/plezy/libmpv/MpvError.kt b/android/libmpv/src/main/java/com/edde746/plezy/libmpv/MpvError.kt new file mode 100644 index 000000000..92ca00741 --- /dev/null +++ b/android/libmpv/src/main/java/com/edde746/plezy/libmpv/MpvError.kt @@ -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 } + } +} diff --git a/android/libmpv/src/main/java/com/edde746/plezy/libmpv/MpvEvent.kt b/android/libmpv/src/main/java/com/edde746/plezy/libmpv/MpvEvent.kt index 7a7978028..62fc75f91 100644 --- a/android/libmpv/src/main/java/com/edde746/plezy/libmpv/MpvEvent.kt +++ b/android/libmpv/src/main/java/com/edde746/plezy/libmpv/MpvEvent.kt @@ -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( diff --git a/android/libmpv/src/main/java/com/edde746/plezy/libmpv/MpvPlayer.kt b/android/libmpv/src/main/java/com/edde746/plezy/libmpv/MpvPlayer.kt index a5f43b4ab..f5827b21c 100644 --- a/android/libmpv/src/main/java/com/edde746/plezy/libmpv/MpvPlayer.kt +++ b/android/libmpv/src/main/java/com/edde746/plezy/libmpv/MpvPlayer.kt @@ -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) ) ) } diff --git a/lib/i18n/az.i18n.json b/lib/i18n/az.i18n.json index 799e1d8b9..ba82cbff9 100644 --- a/lib/i18n/az.i18n.json +++ b/lib/i18n/az.i18n.json @@ -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", diff --git a/lib/i18n/bg.i18n.json b/lib/i18n/bg.i18n.json index 111828074..ab377e44f 100644 --- a/lib/i18n/bg.i18n.json +++ b/lib/i18n/bg.i18n.json @@ -779,6 +779,7 @@ "playbackDataInvalid": "Сървърът върна невалидна информация за възпроизвеждането.", "playbackCancelled": "Възпроизвеждането беше отменено.", "playbackFailed": "Възпроизвеждането не можа да бъде стартирано.", + "audioOutputFailed": "", "mediaUnavailable": "", "errorLoadingFileInfo": "Грешка при зареждане на информация за файла: ${error}", "errorLoadingSeries": "Грешка при зареждане на сериала", diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json index 9026e9ea6..19f294999 100644 --- a/lib/i18n/da.i18n.json +++ b/lib/i18n/da.i18n.json @@ -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", diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index d52dc45bd..eae28782b 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -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", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 4e69d65f9..74d1621e6 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -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", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index 0cddd989c..3cc768cde 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -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", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index 4875d0383..ca0badbb0 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -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", diff --git a/lib/i18n/hu.i18n.json b/lib/i18n/hu.i18n.json index 16410c52b..b94109cde 100644 --- a/lib/i18n/hu.i18n.json +++ b/lib/i18n/hu.i18n.json @@ -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", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index b65f8e03e..3887129cb 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -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", diff --git a/lib/i18n/ja.i18n.json b/lib/i18n/ja.i18n.json index e3985d889..948b2af2a 100644 --- a/lib/i18n/ja.i18n.json +++ b/lib/i18n/ja.i18n.json @@ -775,6 +775,7 @@ "playbackDataInvalid": "サーバーから無効な再生情報が返されました。", "playbackCancelled": "再生がキャンセルされました。", "playbackFailed": "再生を開始できませんでした。", + "audioOutputFailed": "", "mediaUnavailable": "", "errorLoadingFileInfo": "ファイル情報の読み込みエラー: ${error}", "errorLoadingSeries": "シリーズの読み込みエラー", diff --git a/lib/i18n/kk.i18n.json b/lib/i18n/kk.i18n.json index 501d35552..edb777a9a 100644 --- a/lib/i18n/kk.i18n.json +++ b/lib/i18n/kk.i18n.json @@ -779,6 +779,7 @@ "playbackDataInvalid": "Сервер қате ойнату мәліметтерін қайтарды.", "playbackCancelled": "Ойнатудан бас тартылды.", "playbackFailed": "Ойнатуды іске қосу қатесі.", + "audioOutputFailed": "", "mediaUnavailable": "", "errorLoadingFileInfo": "Файл ақпаратын жүктеу қатесі: ${error}", "errorLoadingSeries": "Сериалды жүктеу қатесі", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index 8ced698b7..ec112443b 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -775,6 +775,7 @@ "playbackDataInvalid": "서버에서 잘못된 재생 정보를 반환했습니다.", "playbackCancelled": "재생이 취소되었습니다.", "playbackFailed": "재생을 시작할 수 없습니다.", + "audioOutputFailed": "", "mediaUnavailable": "", "errorLoadingFileInfo": "파일 정보 로딩 중 오류: ${error}", "errorLoadingSeries": "시리즈 로딩 중 오류", diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json index 5aa636605..26876726d 100644 --- a/lib/i18n/nb.i18n.json +++ b/lib/i18n/nb.i18n.json @@ -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", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index 4af7781c3..1ecc76699 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -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", diff --git a/lib/i18n/pl.i18n.json b/lib/i18n/pl.i18n.json index 60ec17062..517de2e41 100644 --- a/lib/i18n/pl.i18n.json +++ b/lib/i18n/pl.i18n.json @@ -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", diff --git a/lib/i18n/pt.i18n.json b/lib/i18n/pt.i18n.json index 4e15e554a..8bb203b3c 100644 --- a/lib/i18n/pt.i18n.json +++ b/lib/i18n/pt.i18n.json @@ -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", diff --git a/lib/i18n/ru.i18n.json b/lib/i18n/ru.i18n.json index 825510a71..afaf8c483 100644 --- a/lib/i18n/ru.i18n.json +++ b/lib/i18n/ru.i18n.json @@ -787,6 +787,7 @@ "playbackDataInvalid": "Сервер вернул некорректные сведения для воспроизведения.", "playbackCancelled": "Воспроизведение отменено.", "playbackFailed": "Не удалось начать воспроизведение.", + "audioOutputFailed": "", "mediaUnavailable": "", "errorLoadingFileInfo": "Ошибка загрузки информации о файле: ${error}", "errorLoadingSeries": "Ошибка загрузки сериала", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 87ab78fba..01a8e323e 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -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 diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index aa9bcab44..b6bfb50ae 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -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', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index d9f5a9e70..cac8f9690 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -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", diff --git a/lib/i18n/tr.i18n.json b/lib/i18n/tr.i18n.json index 744408ba7..a91dd1980 100644 --- a/lib/i18n/tr.i18n.json +++ b/lib/i18n/tr.i18n.json @@ -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", diff --git a/lib/i18n/uz.i18n.json b/lib/i18n/uz.i18n.json index 9d338092f..a1122b7ac 100644 --- a/lib/i18n/uz.i18n.json +++ b/lib/i18n/uz.i18n.json @@ -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", diff --git a/lib/i18n/zh-Hant.i18n.json b/lib/i18n/zh-Hant.i18n.json index 52fe5b129..32b9db960 100644 --- a/lib/i18n/zh-Hant.i18n.json +++ b/lib/i18n/zh-Hant.i18n.json @@ -775,6 +775,7 @@ "playbackDataInvalid": "伺服器傳回的播放資訊無效。", "playbackCancelled": "播放已取消。", "playbackFailed": "無法開始播放。", + "audioOutputFailed": "", "mediaUnavailable": "", "errorLoadingFileInfo": "載入檔案資訊時發生錯誤:${error}", "errorLoadingSeries": "載入影集時發生錯誤", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index 36b3f60b6..b143a0126 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -775,6 +775,7 @@ "playbackDataInvalid": "服务器返回了无效的播放信息。", "playbackCancelled": "播放已取消。", "playbackFailed": "无法开始播放。", + "audioOutputFailed": "", "mediaUnavailable": "", "errorLoadingFileInfo": "加载文件信息时出错:${error}", "errorLoadingSeries": "加载剧集时出错", diff --git a/lib/mpv/models.dart b/lib/mpv/models.dart index 3efe8a211..8ffc188cb 100644 --- a/lib/mpv/models.dart +++ b/lib/mpv/models.dart @@ -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 diff --git a/lib/screens/video_player/parts/errors.dart b/lib/screens/video_player/parts/errors.dart index fb5f8be78..7b8f72d40 100644 --- a/lib/screens/video_player/parts/errors.dart +++ b/lib/screens/video_player/parts/errors.dart @@ -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()); } } diff --git a/lib/screens/video_player/playback_failure_action.dart b/lib/screens/video_player/playback_failure_action.dart index e68cc48dd..d2c24ed2a 100644 --- a/lib/screens/video_player/playback_failure_action.dart +++ b/lib/screens/video_player/playback_failure_action.dart @@ -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 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; } diff --git a/test/screens/video_player/playback_failure_action_test.dart b/test/screens/video_player/playback_failure_action_test.dart index 75a6aaa6f..cbb231785 100644 --- a/test/screens/video_player/playback_failure_action_test.dart +++ b/test/screens/video_player/playback_failure_action_test.dart @@ -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++) {