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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -779,6 +779,7 @@
|
||||
"playbackDataInvalid": "Сървърът върна невалидна информация за възпроизвеждането.",
|
||||
"playbackCancelled": "Възпроизвеждането беше отменено.",
|
||||
"playbackFailed": "Възпроизвеждането не можа да бъде стартирано.",
|
||||
"audioOutputFailed": "",
|
||||
"mediaUnavailable": "",
|
||||
"errorLoadingFileInfo": "Грешка при зареждане на информация за файла: ${error}",
|
||||
"errorLoadingSeries": "Грешка при зареждане на сериала",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -775,6 +775,7 @@
|
||||
"playbackDataInvalid": "サーバーから無効な再生情報が返されました。",
|
||||
"playbackCancelled": "再生がキャンセルされました。",
|
||||
"playbackFailed": "再生を開始できませんでした。",
|
||||
"audioOutputFailed": "",
|
||||
"mediaUnavailable": "",
|
||||
"errorLoadingFileInfo": "ファイル情報の読み込みエラー: ${error}",
|
||||
"errorLoadingSeries": "シリーズの読み込みエラー",
|
||||
|
||||
@@ -779,6 +779,7 @@
|
||||
"playbackDataInvalid": "Сервер қате ойнату мәліметтерін қайтарды.",
|
||||
"playbackCancelled": "Ойнатудан бас тартылды.",
|
||||
"playbackFailed": "Ойнатуды іске қосу қатесі.",
|
||||
"audioOutputFailed": "",
|
||||
"mediaUnavailable": "",
|
||||
"errorLoadingFileInfo": "Файл ақпаратын жүктеу қатесі: ${error}",
|
||||
"errorLoadingSeries": "Сериалды жүктеу қатесі",
|
||||
|
||||
@@ -775,6 +775,7 @@
|
||||
"playbackDataInvalid": "서버에서 잘못된 재생 정보를 반환했습니다.",
|
||||
"playbackCancelled": "재생이 취소되었습니다.",
|
||||
"playbackFailed": "재생을 시작할 수 없습니다.",
|
||||
"audioOutputFailed": "",
|
||||
"mediaUnavailable": "",
|
||||
"errorLoadingFileInfo": "파일 정보 로딩 중 오류: ${error}",
|
||||
"errorLoadingSeries": "시리즈 로딩 중 오류",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -787,6 +787,7 @@
|
||||
"playbackDataInvalid": "Сервер вернул некорректные сведения для воспроизведения.",
|
||||
"playbackCancelled": "Воспроизведение отменено.",
|
||||
"playbackFailed": "Не удалось начать воспроизведение.",
|
||||
"audioOutputFailed": "",
|
||||
"mediaUnavailable": "",
|
||||
"errorLoadingFileInfo": "Ошибка загрузки информации о файле: ${error}",
|
||||
"errorLoadingSeries": "Ошибка загрузки сериала",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -775,6 +775,7 @@
|
||||
"playbackDataInvalid": "伺服器傳回的播放資訊無效。",
|
||||
"playbackCancelled": "播放已取消。",
|
||||
"playbackFailed": "無法開始播放。",
|
||||
"audioOutputFailed": "",
|
||||
"mediaUnavailable": "",
|
||||
"errorLoadingFileInfo": "載入檔案資訊時發生錯誤:${error}",
|
||||
"errorLoadingSeries": "載入影集時發生錯誤",
|
||||
|
||||
@@ -775,6 +775,7 @@
|
||||
"playbackDataInvalid": "服务器返回了无效的播放信息。",
|
||||
"playbackCancelled": "播放已取消。",
|
||||
"playbackFailed": "无法开始播放。",
|
||||
"audioOutputFailed": "",
|
||||
"mediaUnavailable": "",
|
||||
"errorLoadingFileInfo": "加载文件信息时出错:${error}",
|
||||
"errorLoadingSeries": "加载剧集时出错",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
Reference in New Issue
Block a user