feat(ui): add ranked playback diagnostic panel

This commit is contained in:
4gray committed 2026-08-04 21:27:33 +02:00
1 parent 5cc10ad7e2
commit 5e489b537e
27 files changed
+2194

No files matched your search

+10
View File
@@ -561,6 +561,16 @@
},
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
"INLINE_FAILURE_TITLE": "Copy the URL or try again",
"RECOMMENDATIONS_LABEL": "إجراءات الاسترداد الموصى بها",
"ACTION_TRY_PLAYER": "جرّب {{player}}",
"ACTION_TRY_PLAYER_HINT": "مؤقت لهذا العنصر؛ سيبقى المشغل المحفوظ دون تغيير",
"REASON_RETRY_TRANSIENT_FAILURE": "أعد المحاولة بنفس المشغل بعد فشل مؤقت في التحميل",
"REASON_RETRY_UNKNOWN_FAILURE": "أعد المحاولة لأن الخطأ لم يحدد مشغلًا آخر متوافقًا",
"REASON_ALTERNATIVE_SOURCE_AVAILABLE": "جرّب مصدرًا آخر متاحًا للمحتوى نفسه",
"REASON_DIFFERENT_ENGINE_FAMILY": "يستخدم هذا المشغل محرك تشغيل مختلفًا في المتصفح",
"REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "تدعم المشغلات الأصلية مزيدًا من برامج الترميز والحاويات",
"REASON_EXTERNAL_BROWSER_ACCESS": "قد يتجاوز المشغل الأصلي قيود الوصول في المتصفح",
"REASON_COMPATIBLE_DRM_PATH": "هذا الهدف متوافق مع بيانات التشفير المتاحة",
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_OPEN_MPV_HINT": "configured player",
+10
View File
@@ -561,6 +561,16 @@
},
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
"INLINE_FAILURE_TITLE": "Copy the URL or try again",
"RECOMMENDATIONS_LABEL": "إجراءات الاسترجاع المقترحة",
"ACTION_TRY_PLAYER": "جرّب {{player}}",
"ACTION_TRY_PLAYER_HINT": "مؤقت غير لهاد المحتوى؛ المشغل المحفوظ غيبقى بلا تغيير",
"REASON_RETRY_TRANSIENT_FAILURE": "عاود جرّب نفس المشغل من بعد مشكل مؤقت فالتحميل",
"REASON_RETRY_UNKNOWN_FAILURE": "عاود جرّب حيث الخطأ ما حدّد حتى مشغل آخر متوافق",
"REASON_ALTERNATIVE_SOURCE_AVAILABLE": "جرّب مصدر آخر متوفر لنفس المحتوى",
"REASON_DIFFERENT_ENGINE_FAMILY": "هاد المشغل كيستعمل محرك تشغيل مختلف فالمتصفح",
"REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "المشغلات الأصلية كتدعم كوديكات وحاويات أكثر",
"REASON_EXTERNAL_BROWSER_ACCESS": "يقدر مشغل أصلي يتجاوز قيود الولوج ديال المتصفح",
"REASON_COMPATIBLE_DRM_PATH": "هاد الهدف متوافق مع بيانات التشفير المتوفرة",
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_OPEN_MPV_HINT": "configured player",
+10
View File
@@ -561,6 +561,16 @@
},
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
"INLINE_FAILURE_TITLE": "Copy the URL or try again",
"RECOMMENDATIONS_LABEL": "Рэкамендаваныя дзеянні для аднаўлення",
"ACTION_TRY_PLAYER": "Паспрабаваць {{player}}",
"ACTION_TRY_PLAYER_HINT": "Часова толькі для гэтага элемента; захаваны плэер не зменіцца",
"REASON_RETRY_TRANSIENT_FAILURE": "Паўтарыць у тым жа плэеры пасля часовага збою загрузкі",
"REASON_RETRY_UNKNOWN_FAILURE": "Паўтарыць, бо памылка не вызначыла іншы сумяшчальны плэер",
"REASON_ALTERNATIVE_SOURCE_AVAILABLE": "Паспрабаваць іншую даступную крыніцу таго ж змесціва",
"REASON_DIFFERENT_ENGINE_FAMILY": "Гэты плэер выкарыстоўвае іншы рухавік прайгравання ў браўзеры",
"REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "Сістэмныя плэеры падтрымліваюць больш кодэкаў і кантэйнераў",
"REASON_EXTERNAL_BROWSER_ACCESS": "Сістэмны плэер можа абысці абмежаванні доступу браўзера",
"REASON_COMPATIBLE_DRM_PATH": "Гэтая мэта сумяшчальная з даступнымі данымі шыфравання",
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_OPEN_MPV_HINT": "configured player",
+10
View File
@@ -561,6 +561,16 @@
},
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
"INLINE_FAILURE_TITLE": "Copy the URL or try again",
"RECOMMENDATIONS_LABEL": "Empfohlene Wiederherstellungsaktionen",
"ACTION_TRY_PLAYER": "{{player}} ausprobieren",
"ACTION_TRY_PLAYER_HINT": "Nur vorübergehend für diesen Inhalt; der gespeicherte Player bleibt unverändert",
"REASON_RETRY_TRANSIENT_FAILURE": "Nach einem vorübergehenden Ladefehler denselben Player erneut versuchen",
"REASON_RETRY_UNKNOWN_FAILURE": "Erneut versuchen, da der Fehler keinen anderen kompatiblen Player erkennen ließ",
"REASON_ALTERNATIVE_SOURCE_AVAILABLE": "Eine andere verfügbare Quelle für denselben Inhalt ausprobieren",
"REASON_DIFFERENT_ENGINE_FAMILY": "Dieser Player verwendet eine andere Browser-Wiedergabe-Engine",
"REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "Native Player unterstützen mehr Codecs und Container",
"REASON_EXTERNAL_BROWSER_ACCESS": "Ein nativer Player kann Zugriffsbeschränkungen des Browsers umgehen",
"REASON_COMPATIBLE_DRM_PATH": "Dieses Ziel ist mit den verfügbaren Verschlüsselungsdaten kompatibel",
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_OPEN_MPV_HINT": "configured player",
+10
View File
@@ -561,6 +561,16 @@
},
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
"INLINE_FAILURE_TITLE": "Copy the URL or try again",
"RECOMMENDATIONS_LABEL": "Προτεινόμενες ενέργειες ανάκτησης",
"ACTION_TRY_PLAYER": "Δοκιμή του {{player}}",
"ACTION_TRY_PLAYER_HINT": "Προσωρινά για αυτό το στοιχείο· το αποθηκευμένο πρόγραμμα αναπαραγωγής δεν αλλάζει",
"REASON_RETRY_TRANSIENT_FAILURE": "Δοκιμάστε ξανά το ίδιο πρόγραμμα αναπαραγωγής μετά από μια προσωρινή αποτυχία φόρτωσης",
"REASON_RETRY_UNKNOWN_FAILURE": "Δοκιμάστε ξανά, επειδή το σφάλμα δεν εντόπισε άλλο συμβατό πρόγραμμα αναπαραγωγής",
"REASON_ALTERNATIVE_SOURCE_AVAILABLE": "Δοκιμάστε μια άλλη διαθέσιμη πηγή για το ίδιο περιεχόμενο",
"REASON_DIFFERENT_ENGINE_FAMILY": "Αυτό το πρόγραμμα αναπαραγωγής χρησιμοποιεί διαφορετική μηχανή αναπαραγωγής του προγράμματος περιήγησης",
"REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "Τα εγγενή προγράμματα αναπαραγωγής υποστηρίζουν περισσότερους κωδικοποιητές και περιέκτες",
"REASON_EXTERNAL_BROWSER_ACCESS": "Ένα εγγενές πρόγραμμα αναπαραγωγής μπορεί να παρακάμψει περιορισμούς πρόσβασης του προγράμματος περιήγησης",
"REASON_COMPATIBLE_DRM_PATH": "Αυτός ο στόχος είναι συμβατός με τα διαθέσιμα δεδομένα κρυπτογράφησης",
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_OPEN_MPV_HINT": "configured player",
+10
View File
@@ -561,6 +561,16 @@
},
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
"INLINE_FAILURE_TITLE": "Copy the URL or try again",
"RECOMMENDATIONS_LABEL": "Recommended recovery actions",
"ACTION_TRY_PLAYER": "Try {{player}}",
"ACTION_TRY_PLAYER_HINT": "Temporary for this item; your saved player stays unchanged",
"REASON_RETRY_TRANSIENT_FAILURE": "Retry the same player after a temporary loading failure",
"REASON_RETRY_UNKNOWN_FAILURE": "Retry because the failure did not identify another compatible player",
"REASON_ALTERNATIVE_SOURCE_AVAILABLE": "Try another available source for the same content",
"REASON_DIFFERENT_ENGINE_FAMILY": "This player uses a different browser playback engine",
"REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "Native players support more codecs and containers",
"REASON_EXTERNAL_BROWSER_ACCESS": "A native player may avoid browser access restrictions",
"REASON_COMPATIBLE_DRM_PATH": "This target is compatible with the available encryption data",
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_OPEN_MPV_HINT": "configured player",
+10
View File
@@ -561,6 +561,16 @@
},
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
"INLINE_FAILURE_TITLE": "Copy the URL or try again",
"RECOMMENDATIONS_LABEL": "Acciones de recuperación recomendadas",
"ACTION_TRY_PLAYER": "Probar {{player}}",
"ACTION_TRY_PLAYER_HINT": "Temporal para este elemento; el reproductor guardado no cambiará",
"REASON_RETRY_TRANSIENT_FAILURE": "Reintentar con el mismo reproductor tras un fallo temporal de carga",
"REASON_RETRY_UNKNOWN_FAILURE": "Reintentar porque el error no identificó otro reproductor compatible",
"REASON_ALTERNATIVE_SOURCE_AVAILABLE": "Probar otra fuente disponible para el mismo contenido",
"REASON_DIFFERENT_ENGINE_FAMILY": "Este reproductor usa un motor de reproducción distinto del navegador",
"REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "Los reproductores nativos admiten más códecs y contenedores",
"REASON_EXTERNAL_BROWSER_ACCESS": "Un reproductor nativo puede evitar las restricciones de acceso del navegador",
"REASON_COMPATIBLE_DRM_PATH": "Este destino es compatible con los datos de cifrado disponibles",
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_OPEN_MPV_HINT": "configured player",
+10
View File
@@ -561,6 +561,16 @@
},
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
"INLINE_FAILURE_TITLE": "Copy the URL or try again",
"RECOMMENDATIONS_LABEL": "Actions de récupération recommandées",
"ACTION_TRY_PLAYER": "Essayer {{player}}",
"ACTION_TRY_PLAYER_HINT": "Temporaire pour cet élément ; votre lecteur enregistré reste inchangé",
"REASON_RETRY_TRANSIENT_FAILURE": "Réessayer avec le même lecteur après un échec temporaire de chargement",
"REASON_RETRY_UNKNOWN_FAILURE": "Réessayer, car l’erreur n’a identifié aucun autre lecteur compatible",
"REASON_ALTERNATIVE_SOURCE_AVAILABLE": "Essayer une autre source disponible pour le même contenu",
"REASON_DIFFERENT_ENGINE_FAMILY": "Ce lecteur utilise un autre moteur de lecture du navigateur",
"REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "Les lecteurs natifs prennent en charge davantage de codecs et de conteneurs",
"REASON_EXTERNAL_BROWSER_ACCESS": "Un lecteur natif peut contourner les restrictions d’accès du navigateur",
"REASON_COMPATIBLE_DRM_PATH": "Cette cible est compatible avec les données de chiffrement disponibles",
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_OPEN_MPV_HINT": "configured player",
+10
View File
@@ -561,6 +561,16 @@
},
"NATIVE_FALLBACK_TITLE": "Nyissa meg inkább külső lejátszóban",
"INLINE_FAILURE_TITLE": "Másolja ki az URL-címet, vagy próbálja újra",
"RECOMMENDATIONS_LABEL": "Ajánlott helyreállítási műveletek",
"ACTION_TRY_PLAYER": "A(z) {{player}} kipróbálása",
"ACTION_TRY_PLAYER_HINT": "Csak ennél az elemnél ideiglenes; a mentett lejátszó változatlan marad",
"REASON_RETRY_TRANSIENT_FAILURE": "Ugyanannak a lejátszónak az újrapróbálása ideiglenes betöltési hiba után",
"REASON_RETRY_UNKNOWN_FAILURE": "Újrapróbálás, mert a hiba nem azonosított más kompatibilis lejátszót",
"REASON_ALTERNATIVE_SOURCE_AVAILABLE": "Másik elérhető forrás kipróbálása ugyanahhoz a tartalomhoz",
"REASON_DIFFERENT_ENGINE_FAMILY": "Ez a lejátszó más böngészős lejátszómotort használ",
"REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "A natív lejátszók több kodeket és konténert támogatnak",
"REASON_EXTERNAL_BROWSER_ACCESS": "Egy natív lejátszó megkerülheti a böngésző hozzáférési korlátozásait",
"REASON_COMPATIBLE_DRM_PATH": "Ez a cél kompatibilis az elérhető titkosítási adatokkal",
"ACTION_OPEN_MPV": "Megnyitás MPV-ben",
"ACTION_OPEN_VLC": "Megnyitás VLC-ben",
"ACTION_OPEN_MPV_HINT": "beállított lejátszó",
+10
View File
@@ -561,6 +561,16 @@
},
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
"INLINE_FAILURE_TITLE": "Copy the URL or try again",
"RECOMMENDATIONS_LABEL": "Azioni di ripristino consigliate",
"ACTION_TRY_PLAYER": "Prova {{player}}",
"ACTION_TRY_PLAYER_HINT": "Temporaneo per questo elemento; il lettore salvato resta invariato",
"REASON_RETRY_TRANSIENT_FAILURE": "Riprova con lo stesso lettore dopo un errore temporaneo di caricamento",
"REASON_RETRY_UNKNOWN_FAILURE": "Riprova perché l’errore non ha identificato un altro lettore compatibile",
"REASON_ALTERNATIVE_SOURCE_AVAILABLE": "Prova un’altra fonte disponibile per lo stesso contenuto",
"REASON_DIFFERENT_ENGINE_FAMILY": "Questo lettore usa un motore di riproduzione del browser diverso",
"REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "I lettori nativi supportano più codec e contenitori",
"REASON_EXTERNAL_BROWSER_ACCESS": "Un lettore nativo può evitare le restrizioni di accesso del browser",
"REASON_COMPATIBLE_DRM_PATH": "Questa destinazione è compatibile con i dati di crittografia disponibili",
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_OPEN_MPV_HINT": "configured player",
+10
View File
@@ -561,6 +561,16 @@
},
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
"INLINE_FAILURE_TITLE": "Copy the URL or try again",
"RECOMMENDATIONS_LABEL": "推奨される復旧操作",
"ACTION_TRY_PLAYER": "{{player}} を試す",
"ACTION_TRY_PLAYER_HINT": "この項目にのみ一時的に適用され、保存済みのプレーヤーは変更されません",
"REASON_RETRY_TRANSIENT_FAILURE": "一時的な読み込みエラーの後、同じプレーヤーで再試行します",
"REASON_RETRY_UNKNOWN_FAILURE": "エラーから別の互換プレーヤーを特定できなかったため、再試行します",
"REASON_ALTERNATIVE_SOURCE_AVAILABLE": "同じコンテンツで利用可能な別のソースを試します",
"REASON_DIFFERENT_ENGINE_FAMILY": "このプレーヤーは別のブラウザー再生エンジンを使用します",
"REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "ネイティブプレーヤーはより多くのコーデックとコンテナに対応しています",
"REASON_EXTERNAL_BROWSER_ACCESS": "ネイティブプレーヤーならブラウザーのアクセス制限を回避できる場合があります",
"REASON_COMPATIBLE_DRM_PATH": "この対象は利用可能な暗号化データに対応しています",
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_OPEN_MPV_HINT": "configured player",
+10
View File
@@ -561,6 +561,16 @@
},
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
"INLINE_FAILURE_TITLE": "Copy the URL or try again",
"RECOMMENDATIONS_LABEL": "권장 복구 작업",
"ACTION_TRY_PLAYER": "{{player}} 사용해 보기",
"ACTION_TRY_PLAYER_HINT": "이 항목에만 임시로 적용되며 저장된 플레이어는 변경되지 않습니다",
"REASON_RETRY_TRANSIENT_FAILURE": "일시적인 로드 실패 후 같은 플레이어로 다시 시도합니다",
"REASON_RETRY_UNKNOWN_FAILURE": "오류에서 다른 호환 플레이어를 확인할 수 없어 다시 시도합니다",
"REASON_ALTERNATIVE_SOURCE_AVAILABLE": "같은 콘텐츠에 사용 가능한 다른 소스를 시도합니다",
"REASON_DIFFERENT_ENGINE_FAMILY": "이 플레이어는 다른 브라우저 재생 엔진을 사용합니다",
"REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "네이티브 플레이어는 더 많은 코덱과 컨테이너를 지원합니다",
"REASON_EXTERNAL_BROWSER_ACCESS": "네이티브 플레이어는 브라우저 접근 제한을 피할 수 있습니다",
"REASON_COMPATIBLE_DRM_PATH": "이 대상은 사용 가능한 암호화 데이터와 호환됩니다",
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_OPEN_MPV_HINT": "configured player",
+10
View File
@@ -561,6 +561,16 @@
},
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
"INLINE_FAILURE_TITLE": "Copy the URL or try again",
"RECOMMENDATIONS_LABEL": "Aanbevolen herstelacties",
"ACTION_TRY_PLAYER": "{{player}} proberen",
"ACTION_TRY_PLAYER_HINT": "Tijdelijk voor dit item; je opgeslagen speler blijft ongewijzigd",
"REASON_RETRY_TRANSIENT_FAILURE": "Dezelfde speler opnieuw proberen na een tijdelijke laadfout",
"REASON_RETRY_UNKNOWN_FAILURE": "Opnieuw proberen omdat de fout geen andere compatibele speler heeft vastgesteld",
"REASON_ALTERNATIVE_SOURCE_AVAILABLE": "Een andere beschikbare bron voor dezelfde inhoud proberen",
"REASON_DIFFERENT_ENGINE_FAMILY": "Deze speler gebruikt een andere afspeelengine in de browser",
"REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "Systeemeigen spelers ondersteunen meer codecs en containers",
"REASON_EXTERNAL_BROWSER_ACCESS": "Een systeemeigen speler kan toegangsbeperkingen van de browser omzeilen",
"REASON_COMPATIBLE_DRM_PATH": "Dit doel is compatibel met de beschikbare versleutelingsgegevens",
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_OPEN_MPV_HINT": "configured player",
+10
View File
@@ -561,6 +561,16 @@
},
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
"INLINE_FAILURE_TITLE": "Copy the URL or try again",
"RECOMMENDATIONS_LABEL": "Zalecane działania odzyskiwania",
"ACTION_TRY_PLAYER": "Wypróbuj {{player}}",
"ACTION_TRY_PLAYER_HINT": "Tymczasowo dla tego elementu; zapisany odtwarzacz pozostanie bez zmian",
"REASON_RETRY_TRANSIENT_FAILURE": "Ponów próbę w tym samym odtwarzaczu po tymczasowym błędzie wczytywania",
"REASON_RETRY_UNKNOWN_FAILURE": "Ponów próbę, ponieważ błąd nie wskazał innego zgodnego odtwarzacza",
"REASON_ALTERNATIVE_SOURCE_AVAILABLE": "Wypróbuj inne dostępne źródło tej samej treści",
"REASON_DIFFERENT_ENGINE_FAMILY": "Ten odtwarzacz używa innego silnika odtwarzania w przeglądarce",
"REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "Natywne odtwarzacze obsługują więcej kodeków i kontenerów",
"REASON_EXTERNAL_BROWSER_ACCESS": "Natywny odtwarzacz może ominąć ograniczenia dostępu przeglądarki",
"REASON_COMPATIBLE_DRM_PATH": "Ten cel jest zgodny z dostępnymi danymi szyfrowania",
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_OPEN_MPV_HINT": "configured player",
+10
View File
@@ -561,6 +561,16 @@
},
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
"INLINE_FAILURE_TITLE": "Copy the URL or try again",
"RECOMMENDATIONS_LABEL": "Ações de recuperação recomendadas",
"ACTION_TRY_PLAYER": "Experimentar {{player}}",
"ACTION_TRY_PLAYER_HINT": "Temporário para este item; o reprodutor salvo permanece inalterado",
"REASON_RETRY_TRANSIENT_FAILURE": "Tentar novamente com o mesmo reprodutor após uma falha temporária de carregamento",
"REASON_RETRY_UNKNOWN_FAILURE": "Tentar novamente porque o erro não identificou outro reprodutor compatível",
"REASON_ALTERNATIVE_SOURCE_AVAILABLE": "Experimentar outra fonte disponível para o mesmo conteúdo",
"REASON_DIFFERENT_ENGINE_FAMILY": "Este reprodutor usa um mecanismo de reprodução diferente no navegador",
"REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "Os reprodutores nativos oferecem suporte a mais codecs e contêineres",
"REASON_EXTERNAL_BROWSER_ACCESS": "Um reprodutor nativo pode contornar as restrições de acesso do navegador",
"REASON_COMPATIBLE_DRM_PATH": "Este destino é compatível com os dados de criptografia disponíveis",
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_OPEN_MPV_HINT": "configured player",
+10
View File
@@ -561,6 +561,16 @@
},
"NATIVE_FALLBACK_TITLE": "Откройте поток во внешнем плеере",
"INLINE_FAILURE_TITLE": "Скопируйте URL или повторите попытку",
"RECOMMENDATIONS_LABEL": "Рекомендуемые действия для восстановления",
"ACTION_TRY_PLAYER": "Попробовать {{player}}",
"ACTION_TRY_PLAYER_HINT": "Временно только для этого элемента; сохранённый плеер не изменится",
"REASON_RETRY_TRANSIENT_FAILURE": "Повторить в том же плеере после временной ошибки загрузки",
"REASON_RETRY_UNKNOWN_FAILURE": "Повторить, поскольку ошибка не указала другой совместимый плеер",
"REASON_ALTERNATIVE_SOURCE_AVAILABLE": "Попробовать другой доступный источник того же контента",
"REASON_DIFFERENT_ENGINE_FAMILY": "Этот плеер использует другой движок воспроизведения в браузере",
"REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "Нативные плееры поддерживают больше кодеков и контейнеров",
"REASON_EXTERNAL_BROWSER_ACCESS": "Нативный плеер может обойти ограничения доступа браузера",
"REASON_COMPATIBLE_DRM_PATH": "Эта цель совместима с доступными данными шифрования",
"ACTION_OPEN_MPV": "Открыть в MPV",
"ACTION_OPEN_VLC": "Открыть в VLC",
"ACTION_OPEN_MPV_HINT": "настроенный плеер",
+10
View File
@@ -561,6 +561,16 @@
},
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
"INLINE_FAILURE_TITLE": "Copy the URL or try again",
"RECOMMENDATIONS_LABEL": "Önerilen kurtarma işlemleri",
"ACTION_TRY_PLAYER": "{{player}} ile dene",
"ACTION_TRY_PLAYER_HINT": "Bu öğe için geçicidir; kayıtlı oynatıcınız değişmeden kalır",
"REASON_RETRY_TRANSIENT_FAILURE": "Geçici bir yükleme hatasından sonra aynı oynatıcıyla yeniden deneyin",
"REASON_RETRY_UNKNOWN_FAILURE": "Hata başka bir uyumlu oynatıcı belirlemediği için yeniden deneyin",
"REASON_ALTERNATIVE_SOURCE_AVAILABLE": "Aynı içerik için kullanılabilir başka bir kaynak deneyin",
"REASON_DIFFERENT_ENGINE_FAMILY": "Bu oynatıcı farklı bir tarayıcı oynatma motoru kullanır",
"REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "Yerel oynatıcılar daha fazla codec bileşenini ve kapsayıcıyı destekler",
"REASON_EXTERNAL_BROWSER_ACCESS": "Yerel bir oynatıcı tarayıcı erişim kısıtlamalarını aşabilir",
"REASON_COMPATIBLE_DRM_PATH": "Bu hedef kullanılabilir şifreleme verileriyle uyumludur",
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_OPEN_MPV_HINT": "configured player",
+10
View File
@@ -561,6 +561,16 @@
},
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
"INLINE_FAILURE_TITLE": "Copy the URL or try again",
"RECOMMENDATIONS_LABEL": "推荐的恢复操作",
"ACTION_TRY_PLAYER": "尝试 {{player}}",
"ACTION_TRY_PLAYER_HINT": "仅临时用于此项目;已保存的播放器不会更改",
"REASON_RETRY_TRANSIENT_FAILURE": "临时加载失败后使用同一播放器重试",
"REASON_RETRY_UNKNOWN_FAILURE": "重试,因为该错误未能确定其他兼容的播放器",
"REASON_ALTERNATIVE_SOURCE_AVAILABLE": "为同一内容尝试其他可用源",
"REASON_DIFFERENT_ENGINE_FAMILY": "此播放器使用不同的浏览器播放引擎",
"REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "原生播放器支持更多编解码器和容器",
"REASON_EXTERNAL_BROWSER_ACCESS": "原生播放器可以避开浏览器访问限制",
"REASON_COMPATIBLE_DRM_PATH": "此目标与可用的加密数据兼容",
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_OPEN_MPV_HINT": "configured player",
+10
View File
@@ -561,6 +561,16 @@
},
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
"INLINE_FAILURE_TITLE": "Copy the URL or try again",
"RECOMMENDATIONS_LABEL": "建議的復原操作",
"ACTION_TRY_PLAYER": "嘗試 {{player}}",
"ACTION_TRY_PLAYER_HINT": "僅暫時套用於此項目;已儲存的播放器不會變更",
"REASON_RETRY_TRANSIENT_FAILURE": "暫時載入失敗後使用相同的播放器重試",
"REASON_RETRY_UNKNOWN_FAILURE": "重試,因為此錯誤未能識別其他相容的播放器",
"REASON_ALTERNATIVE_SOURCE_AVAILABLE": "為相同內容嘗試其他可用來源",
"REASON_DIFFERENT_ENGINE_FAMILY": "此播放器使用不同的瀏覽器播放引擎",
"REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "原生播放器支援更多編解碼器和容器",
"REASON_EXTERNAL_BROWSER_ACCESS": "原生播放器可以避開瀏覽器存取限制",
"REASON_COMPATIBLE_DRM_PATH": "此目標與可用的加密資料相容",
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_OPEN_MPV_HINT": "configured player",
@@ -0,0 +1,184 @@
@let issue = diagnostic();
<section
class="web-player-diagnostic"
data-test-id="playback-diagnostic-banner"
role="status"
>
<div class="web-player-diagnostic__content">
<div class="web-player-diagnostic__badge">
<mat-icon aria-hidden="true">warning</mat-icon>
<span>{{ getDiagnosticTitleKey(issue) | translate }}</span>
</div>
<h2 class="web-player-diagnostic__headline">
{{ diagnosticHeadlineKey() | translate }}
</h2>
<div class="web-player-diagnostic__description">
{{ getDiagnosticDescriptionKey(issue) | translate }}
</div>
@if (getDiagnosticMeta(issue); as meta) {
<div class="web-player-diagnostic__meta">
{{ meta }}
</div>
}
@if (getDiagnosticCodecHint(issue); as codecHint) {
<div
class="web-player-diagnostic__codec-hint"
data-test-id="playback-codec-hint"
>
{{
'PLAYBACK_DIAGNOSTICS.CODEC_HINT'
| translate: { codecs: codecHint }
}}
</div>
}
<div
class="web-player-diagnostic__recommendations"
role="group"
[attr.aria-label]="
'PLAYBACK_DIAGNOSTICS.RECOMMENDATIONS_LABEL' | translate
"
>
@for (
recommendation of recommendations();
track getRecommendationKey(recommendation)
) {
@if (recommendation.action === 'alternative-source') {
<fieldset
class="web-player-diagnostic__alternatives dark-theme"
[class.web-player-diagnostic__alternatives--primary]="
recommendation.priority === 'primary'
"
[disabled]="pending()"
data-test-id="playback-alternative-sources"
>
<legend
class="web-player-diagnostic__alternatives-title"
>
{{
'PORTALS.MULTI_SOURCE.TRY_ANOTHER_SOURCE'
| translate
}}
</legend>
@for (
source of visibleAlternatives();
track source.id
) {
<app-vod-source-row
[source]="source"
[showPin]="false"
(playRequested)="
alternativeSourceRequested.emit($event)
"
(checkRequested)="
sourceCheckRequested.emit($event)
"
/>
}
@if (hiddenAlternativeCount() > 0) {
<p class="web-player-diagnostic__alternatives-more">
{{
'PORTALS.MULTI_SOURCE.MORE_SOURCES'
| translate
: {
count: hiddenAlternativeCount(),
}
}}
</p>
}
</fieldset>
} @else {
<button
type="button"
class="web-player-diagnostic__player-card"
[class.web-player-diagnostic__player-card--primary]="
recommendation.priority === 'primary'
"
[attr.data-test-id]="
getRecommendationTestId(recommendation)
"
[disabled]="pending()"
(click)="activate(recommendation)"
>
<mat-icon aria-hidden="true">{{
getRecommendationIcon(recommendation)
}}</mat-icon>
<span class="web-player-diagnostic__player-copy">
<span class="web-player-diagnostic__player-label">
{{
getRecommendationLabelKey(recommendation)
| translate
: getRecommendationParams(
recommendation
)
}}
</span>
<span class="web-player-diagnostic__player-hint">
{{
getRecommendationReasonKey(
recommendation.reason
) | translate
}}
</span>
@if (
isTemporaryBuiltInRecommendation(recommendation)
) {
<span
class="web-player-diagnostic__player-hint"
>
{{
'PLAYBACK_DIAGNOSTICS.ACTION_TRY_PLAYER_HINT'
| translate
}}
</span>
}
</span>
</button>
}
}
</div>
<div class="web-player-diagnostic__utilities">
<button
class="web-player-diagnostic__utility-button"
type="button"
[cdkCopyToClipboard]="playback().streamUrl"
[matTooltip]="playback().streamUrl"
data-test-id="playback-copy-url"
>
<mat-icon aria-hidden="true">content_copy</mat-icon>
{{ 'PLAYBACK_DIAGNOSTICS.ACTION_COPY_URL' | translate }}
</button>
<span
class="web-player-diagnostic__divider"
aria-hidden="true"
></span>
<details
class="web-player-diagnostic__details"
data-test-id="playback-diagnostic-details"
>
<summary>
<mat-icon aria-hidden="true">expand_more</mat-icon>
{{ 'PLAYBACK_DIAGNOSTICS.DETAILS_SUMMARY' | translate }}
</summary>
<dl>
@for (
detail of getDiagnosticDetails(issue);
track detail.labelKey
) {
<div class="web-player-diagnostic__detail-row">
<dt>{{ detail.labelKey | translate }}</dt>
<dd>{{ detail.value }}</dd>
</div>
}
</dl>
</details>
</div>
</div>
</section>
@@ -0,0 +1,530 @@
:host {
display: contents;
}
.web-player-diagnostic {
position: absolute;
inset: 0;
z-index: 5;
display: flex;
align-items: center;
padding: clamp(24px, 9vh, 92px) clamp(20px, 18vw, 216px);
overflow: auto;
background:
radial-gradient(
circle at 18% 18%,
rgb(255 176 76 / 6%),
transparent 28%
),
rgb(0 0 0 / 96%);
color: #f6f6f6;
}
.web-player-diagnostic button,
.web-player-diagnostic summary {
app-region: no-drag;
}
.web-player-diagnostic__content {
width: min(100%, 720px);
min-width: 0;
}
.web-player-diagnostic__badge {
display: inline-flex;
max-width: 100%;
align-items: center;
gap: 8px;
padding: 5px 11px;
border: 1px solid rgb(255 174 66 / 42%);
border-radius: 6px;
background: rgb(255 148 35 / 15%);
color: #ffb24c;
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0;
line-height: 1.1;
text-transform: uppercase;
}
.web-player-diagnostic__badge mat-icon {
width: 16px;
height: 16px;
flex: 0 0 16px;
font-size: 16px;
}
.web-player-diagnostic__badge span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.web-player-diagnostic__headline {
max-width: 660px;
margin: 20px 0 0;
color: #fff;
font-size: 1.65rem;
font-weight: 760;
letter-spacing: 0;
line-height: 1.05;
}
.web-player-diagnostic__description {
max-width: 760px;
margin-top: 12px;
color: rgb(255 255 255 / 73%);
font-size: 1rem;
line-height: 1.48;
}
.web-player-diagnostic__meta {
margin-top: 9px;
color: rgb(255 255 255 / 48%);
font-family: var(--app-monospace-font, monospace);
font-size: 0.82rem;
line-height: 1.35;
overflow-wrap: anywhere;
}
.web-player-diagnostic__codec-hint {
display: inline-block;
max-width: 100%;
margin-top: 12px;
padding: 4px 8px;
border: 1px solid rgb(255 255 255 / 12%);
border-radius: 6px;
background: rgb(255 255 255 / 5%);
color: rgb(255 255 255 / 68%);
font-size: 0.78rem;
font-weight: 620;
line-height: 1.3;
overflow-wrap: anywhere;
}
.web-player-diagnostic__recommendations {
display: grid;
grid-template-columns: repeat(2, minmax(168px, 192px));
gap: 12px;
margin-top: 28px;
}
.web-player-diagnostic__recommendations:empty {
display: none;
}
.web-player-diagnostic__alternatives {
/* The diagnostic surface is always dark, independent of the application
* theme. Map every source-row surface and foreground locally so light-theme
* app tokens cannot leak dark text into this overlay. */
--playback-diagnostic-source-surface: #161b24;
--playback-diagnostic-source-foreground: #f3f6fb;
--playback-diagnostic-source-muted: #b9c2d3;
--playback-diagnostic-source-accent: #8bb7ff;
--playback-diagnostic-source-on-accent: #151515;
--playback-diagnostic-source-hover: #252d3b;
--playback-diagnostic-source-selected: #243b5c;
--playback-diagnostic-source-border: #657086;
--playback-diagnostic-source-ok-foreground: #4ade80;
--playback-diagnostic-source-ok-surface: #173928;
--playback-diagnostic-source-warn-foreground: #ffcf99;
--playback-diagnostic-source-warn-surface: #422e1d;
--playback-diagnostic-source-bad-foreground: #ff9aa6;
--playback-diagnostic-source-bad-surface: #451f27;
--app-on-surface: var(--playback-diagnostic-source-foreground);
--app-eyebrow-color: var(--playback-diagnostic-source-muted);
--app-card-hover-bg: var(--playback-diagnostic-source-hover);
--app-selection-color: var(--playback-diagnostic-source-accent);
--app-selection-surface: var(--playback-diagnostic-source-selected);
--app-widget-bg: var(--playback-diagnostic-source-surface);
--app-widget-border: var(--playback-diagnostic-source-border);
--mat-sys-on-primary: var(--playback-diagnostic-source-on-accent);
width: min(420px, 100%);
min-width: 0;
grid-column: 1 / -1;
margin: 0;
padding: 0;
border: 0;
border-radius: 12px;
background: var(--playback-diagnostic-source-surface);
text-align: left;
max-height: min(46vh, 320px);
overflow-y: auto;
}
/* VodSourceRow defines status variables on its own host. This more-specific,
* panel-owned mapping keeps those tags on the same dark-overlay contract. */
.web-player-diagnostic__alternatives app-vod-source-row {
--vod-source-ok-fg: var(--playback-diagnostic-source-ok-foreground);
--vod-source-ok-bg: var(--playback-diagnostic-source-ok-surface);
--vod-source-warn-fg: var(--playback-diagnostic-source-warn-foreground);
--vod-source-warn-bg: var(--playback-diagnostic-source-warn-surface);
--vod-source-bad-fg: var(--playback-diagnostic-source-bad-foreground);
--vod-source-bad-bg: var(--playback-diagnostic-source-bad-surface);
}
.web-player-diagnostic__alternatives--primary {
outline: 1px solid rgb(255 255 255 / 34%);
outline-offset: 1px;
}
.web-player-diagnostic__alternatives:disabled {
opacity: 0.56;
}
.web-player-diagnostic__alternatives-more {
margin: 0;
padding: 6px 12px 8px;
color: rgb(255 255 255 / 45%);
font-size: 0.75rem;
}
.web-player-diagnostic__alternatives-title {
margin: 0;
padding: 8px 12px 4px;
color: rgb(255 255 255 / 55%);
font-size: 0.594rem;
font-weight: 600;
letter-spacing: 0.12em;
overflow-wrap: anywhere;
text-transform: uppercase;
}
.web-player-diagnostic__player-card {
display: grid;
min-width: 0;
min-height: 60px;
grid-template-columns: 22px minmax(0, 1fr);
align-items: center;
gap: 12px;
padding: 12px 16px;
border: 1px solid rgb(255 255 255 / 13%);
border-radius: 8px;
background: rgb(0 0 0 / 22%);
color: #f5f5f5;
cursor: pointer;
text-align: left;
transition:
background-color 140ms ease,
border-color 140ms ease,
color 140ms ease,
transform 140ms ease;
}
.web-player-diagnostic__player-card:hover {
border-color: rgb(255 255 255 / 28%);
background: rgb(255 255 255 / 6%);
}
.web-player-diagnostic__player-card:active {
transform: translateY(1px);
}
.web-player-diagnostic__player-card:disabled {
cursor: wait;
opacity: 0.56;
transform: none;
}
.web-player-diagnostic__player-card:focus-visible,
.web-player-diagnostic__utility-button:focus-visible,
.web-player-diagnostic__details summary:focus-visible {
outline: 2px solid #ffb24c;
outline-offset: 3px;
}
.web-player-diagnostic__player-card--primary {
border-color: transparent;
background: #fff;
color: #151515;
}
.web-player-diagnostic__player-card--primary:hover {
border-color: transparent;
background: rgb(255 255 255 / 88%);
}
.web-player-diagnostic__player-card mat-icon {
width: 20px;
height: 20px;
font-size: 20px;
}
.web-player-diagnostic__player-copy {
display: grid;
min-width: 0;
gap: 2px;
}
.web-player-diagnostic__player-label {
overflow: hidden;
font-size: 0.95rem;
font-weight: 760;
line-height: 1.05;
text-overflow: ellipsis;
white-space: nowrap;
}
.web-player-diagnostic__player-hint {
min-width: 0;
overflow: visible;
overflow-wrap: anywhere;
color: currentColor;
font-size: 0.78rem;
line-height: 1.1;
opacity: 0.62;
text-overflow: clip;
white-space: normal;
}
.web-player-diagnostic__utilities {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 8px 12px;
margin-top: 22px;
color: rgb(255 255 255 / 45%);
}
.web-player-diagnostic__utility-button,
.web-player-diagnostic__details summary {
display: inline-flex;
min-height: 24px;
align-items: center;
gap: 6px;
padding: 0;
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
font: inherit;
font-size: 0.86rem;
font-weight: 640;
line-height: 1.2;
}
.web-player-diagnostic__utility-button:hover,
.web-player-diagnostic__details summary:hover {
color: rgb(255 255 255 / 76%);
}
.web-player-diagnostic__utility-button mat-icon,
.web-player-diagnostic__details summary mat-icon {
width: 16px;
height: 16px;
font-size: 16px;
}
.web-player-diagnostic__divider {
width: 1px;
height: 18px;
margin-top: 3px;
background: rgb(255 255 255 / 16%);
}
.web-player-diagnostic__details {
min-width: 0;
max-width: 100%;
}
.web-player-diagnostic__details summary {
list-style: none;
}
.web-player-diagnostic__details summary::-webkit-details-marker {
display: none;
}
.web-player-diagnostic__details[open] {
flex-basis: 100%;
}
.web-player-diagnostic__details dl {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
gap: 7px 14px;
max-width: min(100%, 640px);
margin: 12px 0 0;
padding: 12px;
border: 1px solid rgb(255 255 255 / 10%);
border-radius: 8px;
background: rgb(255 255 255 / 5%);
color: rgb(255 255 255 / 68%);
font-size: 0.78rem;
line-height: 1.3;
}
.web-player-diagnostic__detail-row {
display: contents;
}
.web-player-diagnostic__detail-row dt {
color: rgb(255 255 255 / 48%);
font-weight: 700;
white-space: nowrap;
}
.web-player-diagnostic__detail-row dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
}
@container (max-width: 760px) {
.web-player-diagnostic {
align-items: flex-start;
padding: 22px 16px;
}
.web-player-diagnostic__badge,
.web-player-diagnostic__badge span {
white-space: normal;
}
.web-player-diagnostic__headline {
margin-top: 16px;
font-size: 1.48rem;
}
.web-player-diagnostic__utilities {
gap: 10px 12px;
}
.web-player-diagnostic__details dl {
grid-template-columns: minmax(0, 1fr);
max-width: 100%;
}
.web-player-diagnostic__detail-row {
display: grid;
gap: 2px;
}
.web-player-diagnostic__detail-row dt {
white-space: normal;
}
}
@container (max-width: 520px) {
.web-player-diagnostic {
align-items: flex-start;
padding: clamp(14px, 5cqi, 22px);
}
.web-player-diagnostic__content {
width: 100%;
}
.web-player-diagnostic__badge {
gap: 6px;
padding: 4px 8px;
font-size: 0.7rem;
white-space: normal;
}
.web-player-diagnostic__badge span {
white-space: nowrap;
}
.web-player-diagnostic__headline {
margin-top: 14px;
font-size: 1.35rem;
line-height: 1.08;
}
.web-player-diagnostic__description {
margin-top: 10px;
font-size: 0.9rem;
line-height: 1.38;
}
.web-player-diagnostic__meta {
margin-top: 8px;
font-size: 0.74rem;
}
.web-player-diagnostic__codec-hint {
margin-top: 10px;
font-size: 0.72rem;
}
.web-player-diagnostic__recommendations {
grid-template-columns: minmax(0, 1fr);
gap: 8px;
margin-top: 16px;
}
.web-player-diagnostic__alternatives {
width: 100%;
}
.web-player-diagnostic__player-card {
min-height: 52px;
padding: 10px 12px;
}
.web-player-diagnostic__player-label {
overflow-wrap: anywhere;
text-overflow: clip;
white-space: normal;
}
.web-player-diagnostic__utilities {
display: grid;
gap: 7px;
margin-top: 16px;
}
.web-player-diagnostic__divider {
display: none;
}
.web-player-diagnostic__utility-button,
.web-player-diagnostic__details summary {
width: fit-content;
min-height: 22px;
font-size: 0.82rem;
}
.web-player-diagnostic__details[open] {
flex-basis: auto;
}
.web-player-diagnostic__details dl {
max-height: 260px;
overflow: auto;
grid-template-columns: minmax(0, 1fr);
gap: 6px;
margin-top: 8px;
padding: 10px;
font-size: 0.72rem;
}
.web-player-diagnostic__detail-row {
display: grid;
gap: 2px;
}
.web-player-diagnostic__detail-row dt {
white-space: normal;
}
}
@container (max-width: 340px) {
.web-player-diagnostic {
padding: 12px;
}
.web-player-diagnostic__headline {
font-size: 1.22rem;
}
.web-player-diagnostic__description {
font-size: 0.84rem;
}
.web-player-diagnostic__player-label {
font-size: 0.88rem;
}
}
@@ -0,0 +1,583 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import {
PlaybackDiagnosticCode,
PlaybackDiagnosticSource,
PlaybackRecommendationReason,
type PlaybackDiagnostic,
type PlaybackRecommendation,
type PlaybackRecommendationTarget,
} from '@iptvnator/playback/util';
import type {
ResolvedPortalPlayback,
VodSourceDescriptor,
} from '@iptvnator/shared/interfaces';
import { PlaybackDiagnosticPanelComponent } from './playback-diagnostic-panel.component';
const DIAGNOSTIC: PlaybackDiagnostic = {
code: PlaybackDiagnosticCode.UnsupportedContainer,
source: PlaybackDiagnosticSource.Source,
sourceUrl: 'https://example.com/archive/movie.mkv',
container: 'matroska',
mimeType: 'video/matroska',
player: 'videojs',
audioCodecs: [],
videoCodecs: [],
externalFallbackRecommended: true,
};
const PLAYBACK: ResolvedPortalPlayback = {
streamUrl: 'https://example.com/archive/movie.mkv',
title: 'Example Movie',
};
const PANEL_STYLE_SOURCE = readFileSync(
resolve(
process.cwd(),
'libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.scss'
),
'utf8'
);
function contrastRatio(foreground: string, background: string): number {
const luminance = (hex: string): number => {
const channels = [1, 3, 5].map((offset) =>
Number.parseInt(hex.slice(offset, offset + 2), 16)
);
const [red, green, blue] = channels.map((channel) => {
const normalized = channel / 255;
return normalized <= 0.04045
? normalized / 12.92
: ((normalized + 0.055) / 1.055) ** 2.4;
});
return red * 0.2126 + green * 0.7152 + blue * 0.0722;
};
const values = [luminance(foreground), luminance(background)].sort(
(left, right) => right - left
);
return (values[0] + 0.05) / (values[1] + 0.05);
}
function recommendation(
action: PlaybackRecommendation['action'],
overrides: Partial<PlaybackRecommendation> = {}
): PlaybackRecommendation {
if (action === 'retry') {
return {
action,
reason: PlaybackRecommendationReason.RetryTransientFailure,
priority: 'secondary',
...overrides,
} as PlaybackRecommendation;
}
if (action === 'alternative-source') {
return {
action,
reason: PlaybackRecommendationReason.AlternativeSourceAvailable,
priority: 'secondary',
...overrides,
} as PlaybackRecommendation;
}
return {
action,
target: 'html5',
reason: PlaybackRecommendationReason.DifferentEngineFamily,
priority: 'secondary',
...overrides,
} as PlaybackRecommendation;
}
function source(index: number): VodSourceDescriptor {
return {
id: `playlist-${index}:xtream:${index}`,
playlistId: `playlist-${index}`,
playlistName: `Portal ${index}`,
portalType: 'xtream',
contentId: index,
rawTitle: `Example Movie ${index}`,
matchConfidence: 'exact',
isActive: false,
isPinned: false,
isTried: false,
probe: { status: 'idle' },
};
}
describe('PlaybackDiagnosticPanelComponent', () => {
let fixture: ComponentFixture<PlaybackDiagnosticPanelComponent>;
let component: PlaybackDiagnosticPanelComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
PlaybackDiagnosticPanelComponent,
TranslateModule.forRoot(),
],
})
.overrideComponent(PlaybackDiagnosticPanelComponent, {
add: { styles: [PANEL_STYLE_SOURCE] },
})
.compileComponents();
const translate = TestBed.inject(TranslateService);
translate.setTranslation('en', {
PLAYBACK_DIAGNOSTICS: {
ACTION_TRY_PLAYER: 'Try {{player}}',
ACTION_TRY_PLAYER_HINT:
'This temporary player change applies only to the current playback session and leaves your saved preference unchanged.',
REASON_DIFFERENT_ENGINE_FAMILY:
'This player uses a different playback engine that may support the stream format more reliably.',
RECOMMENDATIONS_LABEL: 'Recommended recovery actions',
},
PORTALS: {
MULTI_SOURCE: {
MORE_SOURCES: '{{count}} more sources',
TRY_ANOTHER_SOURCE: 'Try another source',
},
},
});
translate.use('en');
fixture = TestBed.createComponent(PlaybackDiagnosticPanelComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('diagnostic', DIAGNOSTIC);
fixture.componentRef.setInput('recommendations', []);
fixture.componentRef.setInput('playback', PLAYBACK);
});
it('applies the component host style from the actual panel stylesheet', () => {
fixture.detectChanges();
const host = fixture.nativeElement as HTMLElement;
expect(getComputedStyle(host).display).toBe('contents');
});
it('renders supplied policy-ranked action buttons in order with priority styling', () => {
const rankedRecommendations = [
recommendation('player', {
target: 'html5',
priority: 'primary',
}),
recommendation('player', { target: 'mpv' }),
recommendation('retry'),
];
fixture.componentRef.setInput('recommendations', rankedRecommendations);
fixture.detectChanges();
const cards = fixture.debugElement.queryAll(
By.css('.web-player-diagnostic__player-card')
);
expect(cards.map((card) => card.attributes['data-test-id'])).toEqual([
'playback-recommendation-html5',
'playback-fallback-mpv',
'playback-retry',
]);
expect(
cards.map(
(card) =>
card.classes[
'web-player-diagnostic__player-card--primary'
] ?? false
)
).toEqual([true, false, false]);
for (const card of cards) {
const button = card.nativeElement as HTMLButtonElement;
expect(button.tagName).toBe('BUTTON');
expect(button.getAttribute('type')).toBe('button');
expect(button.type).toBe('button');
}
});
it('exposes the recommendation set and alternatives with native grouping semantics', () => {
fixture.componentRef.setInput('recommendations', [
recommendation('alternative-source', { priority: 'primary' }),
]);
fixture.componentRef.setInput('alternativeSources', [source(1)]);
fixture.detectChanges();
const group = fixture.nativeElement.querySelector(
'.web-player-diagnostic__recommendations'
) as HTMLElement;
const alternatives = fixture.nativeElement.querySelector(
'[data-test-id="playback-alternative-sources"]'
) as HTMLFieldSetElement;
const legend = alternatives.firstElementChild as HTMLElement;
expect(group.getAttribute('role')).toBe('group');
expect(group.getAttribute('aria-label')).toBe(
'Recommended recovery actions'
);
expect(legend.tagName).toBe('LEGEND');
expect(legend.classList).toContain(
'web-player-diagnostic__alternatives-title'
);
expect(legend.textContent).toContain('Try another source');
});
it('provides a high-contrast dark token contract to alternative source rows', () => {
fixture.componentRef.setInput('recommendations', [
recommendation('alternative-source', { priority: 'primary' }),
]);
fixture.componentRef.setInput('alternativeSources', [source(1)]);
fixture.detectChanges();
const alternatives = fixture.nativeElement.querySelector(
'[data-test-id="playback-alternative-sources"]'
) as HTMLFieldSetElement;
const styles = getComputedStyle(alternatives);
const token = (name: string): string =>
styles.getPropertyValue(name).trim();
const expectedTokens: Readonly<Record<string, string>> = {
'--playback-diagnostic-source-surface': '#161b24',
'--playback-diagnostic-source-foreground': '#f3f6fb',
'--playback-diagnostic-source-muted': '#b9c2d3',
'--playback-diagnostic-source-accent': '#8bb7ff',
'--playback-diagnostic-source-on-accent': '#151515',
'--playback-diagnostic-source-hover': '#252d3b',
'--playback-diagnostic-source-selected': '#243b5c',
'--playback-diagnostic-source-border': '#657086',
'--playback-diagnostic-source-ok-foreground': '#4ade80',
'--playback-diagnostic-source-ok-surface': '#173928',
'--playback-diagnostic-source-warn-foreground': '#ffcf99',
'--playback-diagnostic-source-warn-surface': '#422e1d',
'--playback-diagnostic-source-bad-foreground': '#ff9aa6',
'--playback-diagnostic-source-bad-surface': '#451f27',
};
expect(alternatives.classList).toContain('dark-theme');
for (const [name, value] of Object.entries(expectedTokens)) {
expect(token(name)).toBe(value);
}
expect(PANEL_STYLE_SOURCE).toMatch(
/--app-on-surface:\s*var\(--playback-diagnostic-source-foreground\)/
);
expect(PANEL_STYLE_SOURCE).toMatch(
/--app-eyebrow-color:\s*var\(--playback-diagnostic-source-muted\)/
);
expect(PANEL_STYLE_SOURCE).toMatch(
/--app-card-hover-bg:\s*var\(--playback-diagnostic-source-hover\)/
);
expect(PANEL_STYLE_SOURCE).toMatch(
/--app-selection-surface:\s*var\(--playback-diagnostic-source-selected\)/
);
expect(PANEL_STYLE_SOURCE).toMatch(
/--app-selection-color:\s*var\(--playback-diagnostic-source-accent\)/
);
expect(PANEL_STYLE_SOURCE).toMatch(
/--app-widget-border:\s*var\(--playback-diagnostic-source-border\)/
);
expect(PANEL_STYLE_SOURCE).toMatch(
/--app-widget-bg:\s*var\(--playback-diagnostic-source-surface\)/
);
expect(PANEL_STYLE_SOURCE).toMatch(
/--mat-sys-on-primary:\s*var\(--playback-diagnostic-source-on-accent\)/
);
expect(PANEL_STYLE_SOURCE).toContain('--vod-source-ok-fg:');
expect(PANEL_STYLE_SOURCE).toContain('--vod-source-warn-fg:');
expect(PANEL_STYLE_SOURCE).toContain('--vod-source-bad-fg:');
expect(PANEL_STYLE_SOURCE).toMatch(
/web-player-diagnostic__alternatives app-vod-source-row[\s\S]*--vod-source-ok-fg:/
);
const color = (name: string): string => expectedTokens[name];
const surface = color('--playback-diagnostic-source-surface');
for (const foreground of [
'--playback-diagnostic-source-foreground',
'--playback-diagnostic-source-muted',
'--playback-diagnostic-source-accent',
]) {
expect(
contrastRatio(color(foreground), surface)
).toBeGreaterThanOrEqual(4.5);
}
expect(
contrastRatio(color('--playback-diagnostic-source-border'), surface)
).toBeGreaterThanOrEqual(3);
for (const tone of ['ok', 'warn', 'bad']) {
expect(
contrastRatio(
color(`--playback-diagnostic-source-${tone}-foreground`),
color(`--playback-diagnostic-source-${tone}-surface`)
)
).toBeGreaterThanOrEqual(4.5);
}
expect(
contrastRatio(
color('--playback-diagnostic-source-on-accent'),
color('--playback-diagnostic-source-accent')
)
).toBeGreaterThanOrEqual(4.5);
});
it('disables recommendation buttons and source controls while pending', () => {
fixture.componentRef.setInput('recommendations', [
recommendation('retry', { priority: 'primary' }),
recommendation('alternative-source'),
]);
fixture.componentRef.setInput('alternativeSources', [source(1)]);
fixture.componentRef.setInput('pending', true);
fixture.detectChanges();
const retry = fixture.nativeElement.querySelector(
'[data-test-id="playback-retry"]'
) as HTMLButtonElement;
const alternatives = fixture.nativeElement.querySelector(
'[data-test-id="playback-alternative-sources"]'
) as HTMLFieldSetElement;
const sourcePlay = fixture.nativeElement.querySelector(
'.source-row__action--play'
) as HTMLButtonElement;
expect(retry.disabled).toBe(true);
expect(alternatives.disabled).toBe(true);
expect(sourcePlay.matches(':disabled')).toBe(true);
});
it('bounds alternative sources to five and reports the hidden count', () => {
fixture.componentRef.setInput('recommendations', [
recommendation('alternative-source', { priority: 'primary' }),
]);
fixture.componentRef.setInput(
'alternativeSources',
Array.from({ length: 7 }, (_, index) => source(index + 1))
);
fixture.detectChanges();
expect(
fixture.debugElement.queryAll(By.css('app-vod-source-row'))
).toHaveLength(5);
expect(
fixture.nativeElement.querySelector(
'.web-player-diagnostic__alternatives-more'
).textContent
).toContain('2');
});
it('emits exact retry, player, source play, and source check requests', () => {
const retries: void[] = [];
const players: PlaybackRecommendationTarget[] = [];
const playedSources: string[] = [];
const checkedSources: string[] = [];
component.retryRequested.subscribe(() => retries.push(undefined));
component.playerRequested.subscribe((target) => players.push(target));
component.alternativeSourceRequested.subscribe((id) =>
playedSources.push(id)
);
component.sourceCheckRequested.subscribe((id) =>
checkedSources.push(id)
);
fixture.componentRef.setInput('recommendations', [
recommendation('player', {
target: 'artplayer',
priority: 'primary',
}),
recommendation('retry'),
recommendation('alternative-source'),
]);
fixture.componentRef.setInput('alternativeSources', [source(8)]);
fixture.detectChanges();
(
fixture.nativeElement.querySelector(
'[data-test-id="playback-recommendation-artplayer"]'
) as HTMLButtonElement
).click();
(
fixture.nativeElement.querySelector(
'[data-test-id="playback-retry"]'
) as HTMLButtonElement
).click();
(
fixture.nativeElement.querySelector(
'.source-row__action--play'
) as HTMLButtonElement
).click();
(
fixture.nativeElement.querySelector(
'.source-tag--action'
) as HTMLButtonElement
).click();
expect(players).toEqual(['artplayer']);
expect(retries).toEqual([undefined]);
expect(playedSources).toEqual(['playlist-8:xtream:8']);
expect(checkedSources).toEqual(['playlist-8:xtream:8']);
});
it('always renders Copy URL and Technical details utilities', () => {
fixture.detectChanges();
const copy = fixture.nativeElement.querySelector(
'[data-test-id="playback-copy-url"]'
) as HTMLButtonElement;
const details = fixture.nativeElement.querySelector(
'[data-test-id="playback-diagnostic-details"]'
) as HTMLDetailsElement;
expect(copy).not.toBeNull();
expect(copy.type).toBe('button');
expect(copy.textContent).toContain(
'PLAYBACK_DIAGNOSTICS.ACTION_COPY_URL'
);
expect(details).not.toBeNull();
expect(details.textContent).toContain(
'PLAYBACK_DIAGNOSTICS.DETAILS_SUMMARY'
);
});
it('derives native-player copy from actual ranked MPV or VLC entries', () => {
const browserIssue: PlaybackDiagnostic = {
...DIAGNOSTIC,
code: PlaybackDiagnosticCode.BrowserAccessError,
};
fixture.componentRef.setInput('diagnostic', browserIssue);
fixture.componentRef.setInput('recommendations', [
recommendation('player', {
target: 'vlc',
priority: 'primary',
reason: PlaybackRecommendationReason.ExternalBrowserAccess,
}),
]);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain(
'PLAYBACK_DIAGNOSTICS.NATIVE_FALLBACK_TITLE'
);
expect(fixture.nativeElement.textContent).toContain(
'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.DESCRIPTION'
);
fixture.componentRef.setInput('recommendations', []);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain(
'PLAYBACK_DIAGNOSTICS.INLINE_FAILURE_TITLE'
);
expect(fixture.nativeElement.textContent).toContain(
'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.PWA_DESCRIPTION'
);
});
it('does not infer external recovery copy for DRM without a ranked external target', () => {
fixture.componentRef.setInput('diagnostic', {
...DIAGNOSTIC,
code: PlaybackDiagnosticCode.DrmOrEncryption,
});
fixture.componentRef.setInput('recommendations', [
recommendation('alternative-source', { priority: 'primary' }),
]);
fixture.componentRef.setInput('alternativeSources', [source(1)]);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain(
'PLAYBACK_DIAGNOSTICS.INLINE_FAILURE_TITLE'
);
expect(fixture.nativeElement.textContent).not.toContain(
'PLAYBACK_DIAGNOSTICS.NATIVE_FALLBACK_TITLE'
);
});
it('shows the temporary hint only for built-in player targets', () => {
fixture.componentRef.setInput('recommendations', [
recommendation('player', {
target: 'videojs',
priority: 'primary',
}),
recommendation('player', { target: 'mpv' }),
recommendation('player', { target: 'vlc' }),
]);
fixture.detectChanges();
const builtIn = fixture.nativeElement.querySelector(
'[data-test-id="playback-recommendation-videojs"]'
) as HTMLButtonElement;
const mpv = fixture.nativeElement.querySelector(
'[data-test-id="playback-fallback-mpv"]'
) as HTMLButtonElement;
const vlc = fixture.nativeElement.querySelector(
'[data-test-id="playback-fallback-vlc"]'
) as HTMLButtonElement;
expect(builtIn.textContent).toContain(
'leaves your saved preference unchanged'
);
expect(mpv.textContent).not.toContain(
'leaves your saved preference unchanged'
);
expect(vlc.textContent).not.toContain(
'leaves your saved preference unchanged'
);
});
it('wraps long translated reasons and temporary hints without clipping', () => {
fixture.componentRef.setInput('recommendations', [
recommendation('player', {
target: 'videojs',
priority: 'primary',
}),
]);
fixture.detectChanges();
const hints = Array.from(
fixture.nativeElement.querySelectorAll(
'.web-player-diagnostic__player-hint'
) as NodeListOf<HTMLElement>
);
expect(hints).toHaveLength(2);
expect(hints[0].textContent).toContain(
'different playback engine that may support the stream format'
);
expect(hints[1].textContent).toContain(
'leaves your saved preference unchanged'
);
for (const hint of hints) {
const styles = getComputedStyle(hint);
expect(styles.whiteSpace).toBe('normal');
expect(styles.overflowWrap).toBe('anywhere');
expect(styles.textOverflow).toBe('clip');
expect(styles.overflow).not.toBe('hidden');
}
});
it('uses native focus order without autofocus or a focus trap', () => {
fixture.componentRef.setInput('recommendations', [
recommendation('retry', { priority: 'primary' }),
]);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('[autofocus], [cdkTrapFocus]')
).toBeNull();
expect(document.activeElement).not.toBe(
fixture.nativeElement.querySelector(
'[data-test-id="playback-retry"]'
)
);
});
it('declares focus, container-query, and drag-region CSS contracts for browser integration', () => {
const styles = PANEL_STYLE_SOURCE;
expect(styles).toContain(
'.web-player-diagnostic__player-card:focus-visible'
);
expect(styles).toContain('outline: 2px solid #ffb24c');
expect(styles).toContain('@container (max-width: 520px)');
expect(styles).toMatch(
/web-player-diagnostic__recommendations[\s\S]*grid-template-columns:\s*minmax\(0, 1fr\)/
);
expect(styles).toContain('flex-wrap: wrap');
expect(styles).toContain('min-width: 0');
expect(styles).toContain('overflow-wrap: anywhere');
expect(styles).toContain('app-region: no-drag');
});
});
@@ -0,0 +1,120 @@
import { ClipboardModule } from '@angular/cdk/clipboard';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
} from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslateModule } from '@ngx-translate/core';
import {
type PlaybackDiagnostic,
type PlaybackRecommendation,
type PlaybackRecommendationTarget,
} from '@iptvnator/playback/util';
import type {
ResolvedPortalPlayback,
VodSourceDescriptor,
} from '@iptvnator/shared/interfaces';
import { VodSourceRowComponent } from '@iptvnator/ui/components';
import {
getDiagnosticCodecHint,
getDiagnosticDescriptionKey,
getDiagnosticDetails,
getDiagnosticMeta,
getDiagnosticTitleKey,
} from './playback-diagnostic-view.util';
import {
getRecommendationIcon,
getRecommendationKey,
getRecommendationLabelKey,
getRecommendationParams,
getRecommendationReasonKey,
getRecommendationTestId,
isExternalPlayerRecommendation,
isTemporaryBuiltInRecommendation,
} from './playback-recommendation-view.util';
/** How many recovery options the error screen shows before it stops helping. */
const ERROR_SCREEN_ALTERNATIVES = 5;
@Component({
selector: 'app-playback-diagnostic-panel',
templateUrl: './playback-diagnostic-panel.component.html',
styleUrl: './playback-diagnostic-panel.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ClipboardModule,
MatIconModule,
MatTooltipModule,
TranslateModule,
VodSourceRowComponent,
],
host: { class: 'playback-diagnostic-panel' },
})
export class PlaybackDiagnosticPanelComponent {
readonly diagnostic = input.required<PlaybackDiagnostic>();
readonly recommendations =
input.required<readonly PlaybackRecommendation[]>();
readonly playback = input.required<ResolvedPortalPlayback>();
readonly alternativeSources = input<readonly VodSourceDescriptor[]>([]);
readonly pending = input(false);
readonly retryRequested = output<void>();
readonly playerRequested = output<PlaybackRecommendationTarget>();
readonly alternativeSourceRequested = output<string>();
readonly sourceCheckRequested = output<string>();
readonly visibleAlternatives = computed(() =>
this.alternativeSources().slice(0, ERROR_SCREEN_ALTERNATIVES)
);
readonly hiddenAlternativeCount = computed(() =>
Math.max(
0,
this.alternativeSources().length - ERROR_SCREEN_ALTERNATIVES
)
);
readonly hasExternalPlayerRecommendation = computed(() =>
this.recommendations().some(isExternalPlayerRecommendation)
);
readonly diagnosticHeadlineKey = computed(() =>
this.hasExternalPlayerRecommendation()
? 'PLAYBACK_DIAGNOSTICS.NATIVE_FALLBACK_TITLE'
: 'PLAYBACK_DIAGNOSTICS.INLINE_FAILURE_TITLE'
);
readonly getDiagnosticTitleKey = getDiagnosticTitleKey;
readonly getDiagnosticMeta = getDiagnosticMeta;
readonly getDiagnosticCodecHint = getDiagnosticCodecHint;
readonly getDiagnosticDetails = getDiagnosticDetails;
readonly getRecommendationKey = getRecommendationKey;
readonly getRecommendationTestId = getRecommendationTestId;
readonly getRecommendationIcon = getRecommendationIcon;
readonly getRecommendationLabelKey = getRecommendationLabelKey;
readonly getRecommendationParams = getRecommendationParams;
readonly getRecommendationReasonKey = getRecommendationReasonKey;
readonly isTemporaryBuiltInRecommendation =
isTemporaryBuiltInRecommendation;
getDiagnosticDescriptionKey(issue: PlaybackDiagnostic): string {
return getDiagnosticDescriptionKey(
issue,
this.hasExternalPlayerRecommendation()
);
}
activate(recommendation: PlaybackRecommendation): void {
switch (recommendation.action) {
case 'retry':
this.retryRequested.emit();
return;
case 'player':
this.playerRequested.emit(recommendation.target);
return;
case 'alternative-source':
return;
}
}
}
@@ -0,0 +1,60 @@
import {
PlaybackDiagnosticCode,
PlaybackDiagnosticSource,
type PlaybackDiagnostic,
} from '@iptvnator/playback/util';
import {
getDiagnosticCodecHint,
getDiagnosticDescriptionKey,
getDiagnosticDetails,
getDiagnosticMeta,
getDiagnosticTitleKey,
} from './playback-diagnostic-view.util';
describe('playback diagnostic view formatters', () => {
const issue: PlaybackDiagnostic = {
code: PlaybackDiagnosticCode.UnsupportedCodec,
source: PlaybackDiagnosticSource.Native,
sourceUrl: 'https://example.com/movie.mkv',
container: 'matroska',
mimeType: 'video/matroska',
player: 'videojs',
audioCodecs: ['ac-3'],
videoCodecs: ['hvc1.1.6.L93.B0'],
externalFallbackRecommended: true,
};
it('preserves existing title, metadata, codec and detail formatting', () => {
expect(getDiagnosticTitleKey(issue)).toBe(
'PLAYBACK_DIAGNOSTICS.UNSUPPORTED_CODEC.TITLE'
);
expect(getDiagnosticMeta(issue)).toBe('hvc1.1.6.L93.B0, ac-3');
expect(getDiagnosticCodecHint(issue)).toBe('HEVC, AC-3');
expect(getDiagnosticDetails(issue)).toEqual(
expect.arrayContaining([
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_PLAYER',
value: 'Video.js',
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_SOURCE',
value: 'Native media element',
},
])
);
});
it('uses external recommendation presence for browser-access copy', () => {
const browserIssue: PlaybackDiagnostic = {
...issue,
code: PlaybackDiagnosticCode.BrowserAccessError,
};
expect(getDiagnosticDescriptionKey(browserIssue, true)).toBe(
'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.DESCRIPTION'
);
expect(getDiagnosticDescriptionKey(browserIssue, false)).toBe(
'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.PWA_DESCRIPTION'
);
});
});
@@ -0,0 +1,217 @@
import {
type PlaybackDiagnostic,
PlaybackDiagnosticCode,
getLikelyBrowserUnsupportedCodecLabels,
} from '@iptvnator/playback/util';
export type PlaybackDiagnosticDetail = {
readonly labelKey: string;
readonly value: string;
};
export function getDiagnosticTitleKey(issue: PlaybackDiagnostic): string {
return `${getDiagnosticTranslationBase(issue)}.TITLE`;
}
export function getDiagnosticDescriptionKey(
issue: PlaybackDiagnostic,
supportsManagedExternalPlayers: boolean
): string {
if (
issue.code === PlaybackDiagnosticCode.BrowserAccessError &&
!supportsManagedExternalPlayers
) {
return 'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.PWA_DESCRIPTION';
}
return `${getDiagnosticTranslationBase(issue)}.DESCRIPTION`;
}
export function getDiagnosticMeta(issue: PlaybackDiagnostic): string {
if (issue.httpStatus !== undefined) {
return `HTTP ${issue.httpStatus}`;
}
const codecs = [...issue.videoCodecs, ...issue.audioCodecs].join(', ');
if (codecs) {
return codecs;
}
return issue.container || issue.mimeType || '';
}
export function getDiagnosticCodecHint(issue: PlaybackDiagnostic): string {
return getLikelyBrowserUnsupportedCodecLabels(issue).join(', ');
}
export function getDiagnosticDetails(
issue: PlaybackDiagnostic
): readonly PlaybackDiagnosticDetail[] {
return [
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_CODE',
value: issue.code,
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_PLAYER',
value: formatPlayer(issue.player),
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_SOURCE',
value: formatDiagnosticSource(issue.source),
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_CONTAINER',
value: issue.container,
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_MIME_TYPE',
value: issue.mimeType ?? '',
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_VIDEO_CODECS',
value: issue.videoCodecs.join(', '),
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_AUDIO_CODECS',
value: issue.audioCodecs.join(', '),
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_NATIVE_ERROR_CODE',
value: issue.nativeErrorCode?.toString() ?? '',
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_NATIVE_ERROR_MESSAGE',
value:
issue.vhs || issue.mpegTs || issue.shaka
? ''
: (issue.nativeErrorMessage ?? ''),
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_ERROR_DETAILS',
value: formatDiagnosticErrorDetails(issue),
},
].filter(({ value }) => value.trim().length > 0);
}
function formatDiagnosticErrorDetails(issue: PlaybackDiagnostic): string {
if (issue.mpegTs) {
return [
`stage=${issue.mpegTs.stage}`,
`failure=${issue.mpegTs.failure}`,
`type=${issue.mpegTs.engineType}`,
`details=${issue.mpegTs.engineDetails}`,
`disposition=${issue.mpegTs.disposition}`,
issue.mpegTs.httpStatus === undefined
? ''
: `HTTP ${issue.mpegTs.httpStatus}`,
]
.filter((value) => value.length > 0)
.join(' · ');
}
if (issue.shaka) {
return [
`stage=${issue.shaka.stage}`,
`failure=${issue.shaka.failure}`,
`severity=${issue.shaka.severity}`,
`category=${issue.shaka.category}`,
`code=${issue.shaka.engineCode}`,
`disposition=${issue.shaka.disposition}`,
issue.shaka.httpStatus === undefined
? ''
: `HTTP ${issue.shaka.httpStatus}`,
]
.filter((value) => value.length > 0)
.join(' · ');
}
if (issue.vhs) {
return [
`stage=${issue.vhs.stage}`,
`type=${issue.vhs.engineType}`,
`code=${issue.vhs.mediaErrorCode}`,
`disposition=${issue.vhs.disposition}`,
issue.vhs.httpStatus === undefined
? ''
: `HTTP ${issue.vhs.httpStatus}`,
]
.filter((value) => value.length > 0)
.join(' · ');
}
if (issue.hls) {
return [
`stage=${issue.hls.stage}`,
`failure=${issue.hls.failure}`,
`type=${issue.hls.engineType}`,
`details=${issue.hls.engineDetails}`,
`disposition=${issue.hls.disposition}`,
issue.hls.httpStatus === undefined
? ''
: `HTTP ${issue.hls.httpStatus}`,
]
.filter((value) => value.length > 0)
.join(' · ');
}
return [
issue.httpStatus !== undefined ? `HTTP ${issue.httpStatus}` : '',
issue.nativeErrorType ?? '',
issue.details ?? '',
]
.filter((value) => value.trim().length > 0)
.join(' · ');
}
function getDiagnosticTranslationBase(issue: PlaybackDiagnostic): string {
switch (issue.code) {
case PlaybackDiagnosticCode.UnsupportedContainer:
return 'PLAYBACK_DIAGNOSTICS.UNSUPPORTED_CONTAINER';
case PlaybackDiagnosticCode.UnsupportedCodec:
return 'PLAYBACK_DIAGNOSTICS.UNSUPPORTED_CODEC';
case PlaybackDiagnosticCode.MediaDecodeError:
return 'PLAYBACK_DIAGNOSTICS.MEDIA_DECODE_ERROR';
case PlaybackDiagnosticCode.NetworkError:
return 'PLAYBACK_DIAGNOSTICS.NETWORK_ERROR';
case PlaybackDiagnosticCode.BrowserAccessError:
return 'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR';
case PlaybackDiagnosticCode.DrmOrEncryption:
return 'PLAYBACK_DIAGNOSTICS.DRM_OR_ENCRYPTION';
case PlaybackDiagnosticCode.UnknownPlaybackError:
default:
return 'PLAYBACK_DIAGNOSTICS.UNKNOWN_PLAYBACK_ERROR';
}
}
function formatPlayer(player: PlaybackDiagnostic['player']): string {
switch (player) {
case 'videojs':
return 'Video.js';
case 'html5':
return 'HTML5';
case 'artplayer':
return 'ArtPlayer';
default:
return '';
}
}
function formatDiagnosticSource(source: PlaybackDiagnostic['source']): string {
switch (source) {
case 'vhs':
return 'Video.js / VHS';
case 'hls':
return 'HLS.js';
case 'mpegts':
return 'mpegts.js';
case 'native':
return 'Native media element';
case 'shaka':
return 'Shaka Player';
case 'source':
return 'Stream metadata';
default:
return source;
}
}
@@ -0,0 +1,162 @@
import {
PlaybackRecommendationReason,
type PlaybackRecommendation,
type PlaybackRecommendationPriority,
type PlaybackRecommendationTarget,
} from '@iptvnator/playback/util';
import {
getRecommendationIcon,
getRecommendationKey,
getRecommendationLabelKey,
getRecommendationParams,
getRecommendationReasonKey,
getRecommendationTestId,
isTemporaryBuiltInRecommendation,
} from './playback-recommendation-view.util';
function player(
target: PlaybackRecommendationTarget,
priority: PlaybackRecommendationPriority = 'secondary'
): PlaybackRecommendation {
return {
action: 'player',
target,
reason: PlaybackRecommendationReason.DifferentEngineFamily,
priority,
};
}
function retry(): PlaybackRecommendation {
return {
action: 'retry',
reason: PlaybackRecommendationReason.RetryTransientFailure,
priority: 'primary',
};
}
function alternative(): PlaybackRecommendation {
return {
action: 'alternative-source',
reason: PlaybackRecommendationReason.AlternativeSourceAvailable,
priority: 'secondary',
};
}
describe('playback recommendation view mappings', () => {
it.each([
['videojs', 'playback-recommendation-videojs'],
['html5', 'playback-recommendation-html5'],
['artplayer', 'playback-recommendation-artplayer'],
['mpv', 'playback-fallback-mpv'],
['vlc', 'playback-fallback-vlc'],
] as const)('maps %s to its stable test id', (target, testId) => {
expect(getRecommendationTestId(player(target))).toBe(testId);
});
it('keeps stable retry and alternative-source test ids', () => {
expect(getRecommendationTestId(retry())).toBe('playback-retry');
expect(getRecommendationTestId(alternative())).toBe(
'playback-alternative-sources'
);
});
it.each([
['videojs', 'Video.js'],
['html5', 'HTML5'],
['artplayer', 'ArtPlayer'],
] as const)(
'maps built-in target %s to translated player copy',
(target, name) => {
const recommendation = player(target);
expect(getRecommendationLabelKey(recommendation)).toBe(
'PLAYBACK_DIAGNOSTICS.ACTION_TRY_PLAYER'
);
expect(getRecommendationParams(recommendation)).toEqual({
player: name,
});
expect(getRecommendationIcon(recommendation)).toBe('play_circle');
expect(isTemporaryBuiltInRecommendation(recommendation)).toBe(true);
}
);
it.each([
['mpv', 'MPV', 'PLAYBACK_DIAGNOSTICS.ACTION_OPEN_MPV'],
['vlc', 'VLC', 'PLAYBACK_DIAGNOSTICS.ACTION_OPEN_VLC'],
] as const)(
'maps external target %s without a temporary-player hint',
(target, name, labelKey) => {
const recommendation = player(target);
expect(getRecommendationLabelKey(recommendation)).toBe(labelKey);
expect(getRecommendationParams(recommendation)).toEqual({
player: name,
});
expect(getRecommendationIcon(recommendation)).toBe('open_in_new');
expect(isTemporaryBuiltInRecommendation(recommendation)).toBe(
false
);
}
);
it('maps non-player actions exhaustively', () => {
expect(getRecommendationLabelKey(retry())).toBe(
'PLAYBACK_DIAGNOSTICS.ACTION_RETRY'
);
expect(getRecommendationParams(retry())).toEqual({});
expect(getRecommendationIcon(retry())).toBe('refresh');
expect(getRecommendationKey(retry())).toBe('retry');
expect(isTemporaryBuiltInRecommendation(retry())).toBe(false);
expect(getRecommendationLabelKey(alternative())).toBe(
'PORTALS.MULTI_SOURCE.TRY_ANOTHER_SOURCE'
);
expect(getRecommendationParams(alternative())).toEqual({});
expect(getRecommendationIcon(alternative())).toBe('swap_horiz');
expect(getRecommendationKey(alternative())).toBe('alternative-source');
expect(isTemporaryBuiltInRecommendation(alternative())).toBe(false);
});
it.each([
[
PlaybackRecommendationReason.RetryTransientFailure,
'PLAYBACK_DIAGNOSTICS.REASON_RETRY_TRANSIENT_FAILURE',
],
[
PlaybackRecommendationReason.RetryUnknownFailure,
'PLAYBACK_DIAGNOSTICS.REASON_RETRY_UNKNOWN_FAILURE',
],
[
PlaybackRecommendationReason.AlternativeSourceAvailable,
'PLAYBACK_DIAGNOSTICS.REASON_ALTERNATIVE_SOURCE_AVAILABLE',
],
[
PlaybackRecommendationReason.DifferentEngineFamily,
'PLAYBACK_DIAGNOSTICS.REASON_DIFFERENT_ENGINE_FAMILY',
],
[
PlaybackRecommendationReason.ExternalCodecOrContainerSupport,
'PLAYBACK_DIAGNOSTICS.REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT',
],
[
PlaybackRecommendationReason.ExternalBrowserAccess,
'PLAYBACK_DIAGNOSTICS.REASON_EXTERNAL_BROWSER_ACCESS',
],
[
PlaybackRecommendationReason.CompatibleDrmPath,
'PLAYBACK_DIAGNOSTICS.REASON_COMPATIBLE_DRM_PATH',
],
] as const)('maps reason %s to its translation key', (reason, key) => {
expect(getRecommendationReasonKey(reason)).toBe(key);
});
it('keeps player recommendation keys stable and target-specific', () => {
expect(getRecommendationKey(player('videojs'))).toBe('player-videojs');
expect(getRecommendationKey(player('html5'))).toBe('player-html5');
expect(getRecommendationKey(player('artplayer'))).toBe(
'player-artplayer'
);
expect(getRecommendationKey(player('mpv'))).toBe('player-mpv');
expect(getRecommendationKey(player('vlc'))).toBe('player-vlc');
});
});
@@ -0,0 +1,148 @@
import {
type PlaybackRecommendation,
type PlaybackRecommendationReason,
type PlaybackRecommendationTarget,
} from '@iptvnator/playback/util';
export function getRecommendationKey(
recommendation: PlaybackRecommendation
): string {
return recommendation.action === 'player'
? `player-${recommendation.target}`
: recommendation.action;
}
export function getRecommendationTestId(
recommendation: PlaybackRecommendation
): string {
switch (recommendation.action) {
case 'retry':
return 'playback-retry';
case 'alternative-source':
return 'playback-alternative-sources';
case 'player':
return getPlayerTestId(recommendation.target);
}
}
export function getRecommendationLabelKey(
recommendation: PlaybackRecommendation
): string {
switch (recommendation.action) {
case 'retry':
return 'PLAYBACK_DIAGNOSTICS.ACTION_RETRY';
case 'alternative-source':
return 'PORTALS.MULTI_SOURCE.TRY_ANOTHER_SOURCE';
case 'player':
return getPlayerLabelKey(recommendation.target);
}
}
export function getRecommendationParams(
recommendation: PlaybackRecommendation
): Readonly<Record<string, string>> {
return recommendation.action === 'player'
? { player: getPlayerName(recommendation.target) }
: {};
}
export function getRecommendationIcon(
recommendation: PlaybackRecommendation
): string {
switch (recommendation.action) {
case 'retry':
return 'refresh';
case 'alternative-source':
return 'swap_horiz';
case 'player':
return isExternalTarget(recommendation.target)
? 'open_in_new'
: 'play_circle';
}
}
export function getRecommendationReasonKey(
reason: PlaybackRecommendationReason
): string {
switch (reason) {
case 'retry-transient-failure':
return 'PLAYBACK_DIAGNOSTICS.REASON_RETRY_TRANSIENT_FAILURE';
case 'retry-unknown-failure':
return 'PLAYBACK_DIAGNOSTICS.REASON_RETRY_UNKNOWN_FAILURE';
case 'alternative-source-available':
return 'PLAYBACK_DIAGNOSTICS.REASON_ALTERNATIVE_SOURCE_AVAILABLE';
case 'different-engine-family':
return 'PLAYBACK_DIAGNOSTICS.REASON_DIFFERENT_ENGINE_FAMILY';
case 'external-codec-or-container-support':
return 'PLAYBACK_DIAGNOSTICS.REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT';
case 'external-browser-access':
return 'PLAYBACK_DIAGNOSTICS.REASON_EXTERNAL_BROWSER_ACCESS';
case 'compatible-drm-path':
return 'PLAYBACK_DIAGNOSTICS.REASON_COMPATIBLE_DRM_PATH';
}
}
export function isTemporaryBuiltInRecommendation(
recommendation: PlaybackRecommendation
): boolean {
return (
recommendation.action === 'player' &&
!isExternalTarget(recommendation.target)
);
}
export function isExternalPlayerRecommendation(
recommendation: PlaybackRecommendation
): boolean {
return (
recommendation.action === 'player' &&
isExternalTarget(recommendation.target)
);
}
function getPlayerTestId(target: PlaybackRecommendationTarget): string {
switch (target) {
case 'videojs':
return 'playback-recommendation-videojs';
case 'html5':
return 'playback-recommendation-html5';
case 'artplayer':
return 'playback-recommendation-artplayer';
case 'mpv':
return 'playback-fallback-mpv';
case 'vlc':
return 'playback-fallback-vlc';
}
}
function getPlayerLabelKey(target: PlaybackRecommendationTarget): string {
switch (target) {
case 'videojs':
case 'html5':
case 'artplayer':
return 'PLAYBACK_DIAGNOSTICS.ACTION_TRY_PLAYER';
case 'mpv':
return 'PLAYBACK_DIAGNOSTICS.ACTION_OPEN_MPV';
case 'vlc':
return 'PLAYBACK_DIAGNOSTICS.ACTION_OPEN_VLC';
}
}
function getPlayerName(target: PlaybackRecommendationTarget): string {
switch (target) {
case 'videojs':
return 'Video.js';
case 'html5':
return 'HTML5';
case 'artplayer':
return 'ArtPlayer';
case 'mpv':
return 'MPV';
case 'vlc':
return 'VLC';
}
}
function isExternalTarget(target: PlaybackRecommendationTarget): boolean {
return target === 'mpv' || target === 'vlc';
}