From 5e489b537e73b0fe0f7f53a047ab0423c3c87ca1 Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 4 Aug 2026 01:01:50 +0200 Subject: [PATCH] feat(ui): add ranked playback diagnostic panel --- apps/web/src/assets/i18n/ar.json | 10 + apps/web/src/assets/i18n/ary.json | 10 + apps/web/src/assets/i18n/by.json | 10 + apps/web/src/assets/i18n/de.json | 10 + apps/web/src/assets/i18n/el.json | 10 + apps/web/src/assets/i18n/en.json | 10 + apps/web/src/assets/i18n/es.json | 10 + apps/web/src/assets/i18n/fr.json | 10 + apps/web/src/assets/i18n/hu.json | 10 + apps/web/src/assets/i18n/it.json | 10 + apps/web/src/assets/i18n/ja.json | 10 + apps/web/src/assets/i18n/ko.json | 10 + apps/web/src/assets/i18n/nl.json | 10 + apps/web/src/assets/i18n/pl.json | 10 + apps/web/src/assets/i18n/pt.json | 10 + apps/web/src/assets/i18n/ru.json | 10 + apps/web/src/assets/i18n/tr.json | 10 + apps/web/src/assets/i18n/zh.json | 10 + apps/web/src/assets/i18n/zhtw.json | 10 + .../playback-diagnostic-panel.component.html | 184 ++++++ .../playback-diagnostic-panel.component.scss | 530 ++++++++++++++++ ...layback-diagnostic-panel.component.spec.ts | 583 ++++++++++++++++++ .../playback-diagnostic-panel.component.ts | 120 ++++ .../playback-diagnostic-view.util.spec.ts | 60 ++ .../playback-diagnostic-view.util.ts | 217 +++++++ .../playback-recommendation-view.util.spec.ts | 162 +++++ .../playback-recommendation-view.util.ts | 148 +++++ 27 files changed, 2194 insertions(+) create mode 100644 libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.html create mode 100644 libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.scss create mode 100644 libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.spec.ts create mode 100644 libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.ts create mode 100644 libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-view.util.spec.ts create mode 100644 libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-view.util.ts create mode 100644 libs/ui/playback/src/lib/playback-diagnostic-panel/playback-recommendation-view.util.spec.ts create mode 100644 libs/ui/playback/src/lib/playback-diagnostic-panel/playback-recommendation-view.util.ts diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 01b47a609..554b38e78 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -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", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 0cca90eee..8053ce6fa 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -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", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 3f46c4965..5f0ac42df 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -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", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 4eeeb4e66..549d8b36a 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -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", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index ad300b364..43d522514 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -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", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index e2baf15a4..b118311ea 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -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", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index af6718930..6c73270c1 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -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", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 888e5e739..01c7e269b 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -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", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index e0b62c911..2e8699b8c 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -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ó", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 72ab26444..37de21d9b 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -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", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index e968b7e1b..c233a8544 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -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", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 6691d2f39..bbbb39678 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -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", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 2093410be..d5482d88e 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 266c512bf..476928f2f 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 2248deb26..a097ccfd9 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -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", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 18a52d805..7719dfe9a 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -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": "настроенный плеер", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 2830c03ff..4098a631f 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -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", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 1eb615183..450960909 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -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", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index bd2e1033d..b01cb70b1 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -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", diff --git a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.html b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.html new file mode 100644 index 000000000..a3930a3c8 --- /dev/null +++ b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.html @@ -0,0 +1,184 @@ +@let issue = diagnostic(); +
+
+
+ + {{ getDiagnosticTitleKey(issue) | translate }} +
+ +

+ {{ diagnosticHeadlineKey() | translate }} +

+ +
+ {{ getDiagnosticDescriptionKey(issue) | translate }} +
+ + @if (getDiagnosticMeta(issue); as meta) { +
+ {{ meta }} +
+ } + + @if (getDiagnosticCodecHint(issue); as codecHint) { +
+ {{ + 'PLAYBACK_DIAGNOSTICS.CODEC_HINT' + | translate: { codecs: codecHint } + }} +
+ } + +
+ @for ( + recommendation of recommendations(); + track getRecommendationKey(recommendation) + ) { + @if (recommendation.action === 'alternative-source') { +
+ + {{ + 'PORTALS.MULTI_SOURCE.TRY_ANOTHER_SOURCE' + | translate + }} + + @for ( + source of visibleAlternatives(); + track source.id + ) { + + } + @if (hiddenAlternativeCount() > 0) { +

+ {{ + 'PORTALS.MULTI_SOURCE.MORE_SOURCES' + | translate + : { + count: hiddenAlternativeCount(), + } + }} +

+ } +
+ } @else { + + } + } +
+ +
+ + + + +
+ + + {{ 'PLAYBACK_DIAGNOSTICS.DETAILS_SUMMARY' | translate }} + +
+ @for ( + detail of getDiagnosticDetails(issue); + track detail.labelKey + ) { +
+
{{ detail.labelKey | translate }}
+
{{ detail.value }}
+
+ } +
+
+
+
+
diff --git a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.scss b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.scss new file mode 100644 index 000000000..3503ec392 --- /dev/null +++ b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.scss @@ -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; + } +} diff --git a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.spec.ts b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.spec.ts new file mode 100644 index 000000000..bd97b0e5c --- /dev/null +++ b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.spec.ts @@ -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 { + 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; + 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> = { + '--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 + ); + + 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'); + }); +}); diff --git a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.ts b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.ts new file mode 100644 index 000000000..978f32000 --- /dev/null +++ b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.ts @@ -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(); + readonly recommendations = + input.required(); + readonly playback = input.required(); + readonly alternativeSources = input([]); + readonly pending = input(false); + + readonly retryRequested = output(); + readonly playerRequested = output(); + readonly alternativeSourceRequested = output(); + readonly sourceCheckRequested = output(); + + 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; + } + } +} diff --git a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-view.util.spec.ts b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-view.util.spec.ts new file mode 100644 index 000000000..275669c9d --- /dev/null +++ b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-view.util.spec.ts @@ -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' + ); + }); +}); diff --git a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-view.util.ts b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-view.util.ts new file mode 100644 index 000000000..966c437b8 --- /dev/null +++ b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-view.util.ts @@ -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; + } +} diff --git a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-recommendation-view.util.spec.ts b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-recommendation-view.util.spec.ts new file mode 100644 index 000000000..0bbad462a --- /dev/null +++ b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-recommendation-view.util.spec.ts @@ -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'); + }); +}); diff --git a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-recommendation-view.util.ts b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-recommendation-view.util.ts new file mode 100644 index 000000000..79285ad2c --- /dev/null +++ b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-recommendation-view.util.ts @@ -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> { + 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'; +}