mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
feat(ui): add ranked playback diagnostic panel
This commit is contained in:
1 parent
5cc10ad7e2
commit
5e489b537e
27 files changed
+2194
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ó",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "настроенный плеер",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+184
@@ -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>
|
||||
+530
@@ -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;
|
||||
}
|
||||
}
|
||||
+583
@@ -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');
|
||||
});
|
||||
});
|
||||
+120
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+60
@@ -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;
|
||||
}
|
||||
}
|
||||
+162
@@ -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');
|
||||
});
|
||||
});
|
||||
+148
@@ -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';
|
||||
}
|
||||
Reference in new issue
Block a user