mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
feat(workspace): cinematic rotating dashboard hero (#1725)
This commit is contained in:
1 parent
1d9a563d1a
commit
887ac64d18
42 files changed
+3152
-1062
No files matched your search
@@ -0,0 +1,7 @@
|
||||
---
|
||||
type: feature
|
||||
area: workspace
|
||||
highlight: Cinematic dashboard hero
|
||||
---
|
||||
|
||||
The dashboard hero is now a full-width cinematic banner that rotates between the title you were watching, a favorite channel on air right now, and a few favorites and new arrivals. With TMDB enabled it shows the backdrop, rating, genres and plot; hover, focus or the pause button stop the rotation.
|
||||
@@ -1563,7 +1563,18 @@
|
||||
"RECENTLY_WATCHED_LIVE_TV": "تلفزيون مباشر شوهد مؤخرًا",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "الأفلام والمسلسلات المفضلة",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "ينتهي خلال {{days}} يوم",
|
||||
"SOURCE_EXPIRED": "منتهي"
|
||||
"SOURCE_EXPIRED": "منتهي",
|
||||
"HERO_LABEL": "مميز",
|
||||
"HERO_FAVORITE_CHANNEL": "قناة مفضلة",
|
||||
"HERO_FAVORITE": "مفضل",
|
||||
"HERO_RECENTLY_ADDED": "أضيف مؤخرًا",
|
||||
"HERO_CONTINUE": "متابعة",
|
||||
"HERO_WATCH_LIVE": "شاهد مباشرة",
|
||||
"HERO_DETAILS": "التفاصيل",
|
||||
"HERO_SLIDE_POSITION": "{{index}} من {{count}}",
|
||||
"HERO_SHOW_SLIDE": "عرض {{title}}",
|
||||
"HERO_PAUSE": "إيقاف العرض مؤقتًا",
|
||||
"HERO_PLAY": "تشغيل العرض"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -1563,7 +1563,18 @@
|
||||
"RECENTLY_WATCHED_LIVE_TV": "التلفزة المباشرة المتشافة مؤخراً",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "الأفلام والسلاسل المفضلة",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "كتسالي ف {{days}} يوم",
|
||||
"SOURCE_EXPIRED": "سالات"
|
||||
"SOURCE_EXPIRED": "سالات",
|
||||
"HERO_LABEL": "مختارين",
|
||||
"HERO_FAVORITE_CHANNEL": "قناة مفضلة",
|
||||
"HERO_FAVORITE": "مفضل",
|
||||
"HERO_RECENTLY_ADDED": "تزاد جديد",
|
||||
"HERO_CONTINUE": "كمّل",
|
||||
"HERO_WATCH_LIVE": "تفرج مباشر",
|
||||
"HERO_DETAILS": "التفاصيل",
|
||||
"HERO_SLIDE_POSITION": "{{index}} من {{count}}",
|
||||
"HERO_SHOW_SLIDE": "وري {{title}}",
|
||||
"HERO_PAUSE": "وقف العرض",
|
||||
"HERO_PLAY": "شغّل العرض"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -1563,7 +1563,18 @@
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Нядаўна прагледжанае жывое ТБ",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Любімыя фільмы і серыялы",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "Скончыцца праз {{days}} дз.",
|
||||
"SOURCE_EXPIRED": "Скончыўся"
|
||||
"SOURCE_EXPIRED": "Скончыўся",
|
||||
"HERO_LABEL": "Рэкамендаванае",
|
||||
"HERO_FAVORITE_CHANNEL": "Абраны канал",
|
||||
"HERO_FAVORITE": "Абранае",
|
||||
"HERO_RECENTLY_ADDED": "Нядаўна дададзена",
|
||||
"HERO_CONTINUE": "Працягнуць",
|
||||
"HERO_WATCH_LIVE": "Глядзець эфір",
|
||||
"HERO_DETAILS": "Падрабязней",
|
||||
"HERO_SLIDE_POSITION": "{{index}} з {{count}}",
|
||||
"HERO_SHOW_SLIDE": "Паказаць {{title}}",
|
||||
"HERO_PAUSE": "Прыпыніць паказ",
|
||||
"HERO_PLAY": "Аднавіць паказ"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -1563,7 +1563,18 @@
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Kürzlich angesehenes Live-TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorisierte Filme und Serien",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "Läuft in {{days}} T. ab",
|
||||
"SOURCE_EXPIRED": "Abgelaufen"
|
||||
"SOURCE_EXPIRED": "Abgelaufen",
|
||||
"HERO_LABEL": "Empfohlen",
|
||||
"HERO_FAVORITE_CHANNEL": "Lieblingssender",
|
||||
"HERO_FAVORITE": "Favorit",
|
||||
"HERO_RECENTLY_ADDED": "Kürzlich hinzugefügt",
|
||||
"HERO_CONTINUE": "Fortsetzen",
|
||||
"HERO_WATCH_LIVE": "Live ansehen",
|
||||
"HERO_DETAILS": "Details",
|
||||
"HERO_SLIDE_POSITION": "{{index}} von {{count}}",
|
||||
"HERO_SHOW_SLIDE": "{{title}} anzeigen",
|
||||
"HERO_PAUSE": "Diashow anhalten",
|
||||
"HERO_PLAY": "Diashow abspielen"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -1563,7 +1563,18 @@
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Ζωντανή TV που παρακολουθήθηκε πρόσφατα",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Αγαπημένες ταινίες και σειρές",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "Λήγει σε {{days}} ημ.",
|
||||
"SOURCE_EXPIRED": "Έληξε"
|
||||
"SOURCE_EXPIRED": "Έληξε",
|
||||
"HERO_LABEL": "Προτεινόμενα",
|
||||
"HERO_FAVORITE_CHANNEL": "Αγαπημένο κανάλι",
|
||||
"HERO_FAVORITE": "Αγαπημένο",
|
||||
"HERO_RECENTLY_ADDED": "Προστέθηκε πρόσφατα",
|
||||
"HERO_CONTINUE": "Συνέχεια",
|
||||
"HERO_WATCH_LIVE": "Δείτε ζωντανά",
|
||||
"HERO_DETAILS": "Λεπτομέρειες",
|
||||
"HERO_SLIDE_POSITION": "{{index}} από {{count}}",
|
||||
"HERO_SHOW_SLIDE": "Εμφάνιση {{title}}",
|
||||
"HERO_PAUSE": "Παύση προβολής",
|
||||
"HERO_PLAY": "Αναπαραγωγή προβολής"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -1563,7 +1563,18 @@
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Recently watched live TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "Expires in {{days}} d",
|
||||
"SOURCE_EXPIRED": "Expired"
|
||||
"SOURCE_EXPIRED": "Expired",
|
||||
"HERO_LABEL": "Featured",
|
||||
"HERO_FAVORITE_CHANNEL": "Favorite channel",
|
||||
"HERO_FAVORITE": "Favorite",
|
||||
"HERO_RECENTLY_ADDED": "Recently added",
|
||||
"HERO_CONTINUE": "Continue",
|
||||
"HERO_WATCH_LIVE": "Watch live",
|
||||
"HERO_DETAILS": "Details",
|
||||
"HERO_SLIDE_POSITION": "{{index}} of {{count}}",
|
||||
"HERO_SHOW_SLIDE": "Show {{title}}",
|
||||
"HERO_PAUSE": "Pause slideshow",
|
||||
"HERO_PLAY": "Play slideshow"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -1563,7 +1563,18 @@
|
||||
"RECENTLY_WATCHED_LIVE_TV": "TV en directo vista recientemente",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Películas y series favoritas",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "Caduca en {{days}} d",
|
||||
"SOURCE_EXPIRED": "Caducada"
|
||||
"SOURCE_EXPIRED": "Caducada",
|
||||
"HERO_LABEL": "Destacado",
|
||||
"HERO_FAVORITE_CHANNEL": "Canal favorito",
|
||||
"HERO_FAVORITE": "Favorito",
|
||||
"HERO_RECENTLY_ADDED": "Añadido recientemente",
|
||||
"HERO_CONTINUE": "Continuar",
|
||||
"HERO_WATCH_LIVE": "Ver en directo",
|
||||
"HERO_DETAILS": "Detalles",
|
||||
"HERO_SLIDE_POSITION": "{{index}} de {{count}}",
|
||||
"HERO_SHOW_SLIDE": "Mostrar {{title}}",
|
||||
"HERO_PAUSE": "Pausar presentación",
|
||||
"HERO_PLAY": "Reproducir presentación"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -1563,7 +1563,18 @@
|
||||
"RECENTLY_WATCHED_LIVE_TV": "TV en direct récemment regardée",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Films et séries favoris",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "Expire dans {{days}} j",
|
||||
"SOURCE_EXPIRED": "Expiré"
|
||||
"SOURCE_EXPIRED": "Expiré",
|
||||
"HERO_LABEL": "À la une",
|
||||
"HERO_FAVORITE_CHANNEL": "Chaîne favorite",
|
||||
"HERO_FAVORITE": "Favori",
|
||||
"HERO_RECENTLY_ADDED": "Ajouté récemment",
|
||||
"HERO_CONTINUE": "Reprendre",
|
||||
"HERO_WATCH_LIVE": "Regarder en direct",
|
||||
"HERO_DETAILS": "Détails",
|
||||
"HERO_SLIDE_POSITION": "{{index}} sur {{count}}",
|
||||
"HERO_SHOW_SLIDE": "Afficher {{title}}",
|
||||
"HERO_PAUSE": "Mettre le diaporama en pause",
|
||||
"HERO_PLAY": "Lancer le diaporama"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -1563,7 +1563,18 @@
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Legutóbb nézett élő TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Kedvenc filmek és sorozatok",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "{{days}} nap múlva lejár",
|
||||
"SOURCE_EXPIRED": "Lejárt"
|
||||
"SOURCE_EXPIRED": "Lejárt",
|
||||
"HERO_LABEL": "Kiemelt",
|
||||
"HERO_FAVORITE_CHANNEL": "Kedvenc csatorna",
|
||||
"HERO_FAVORITE": "Kedvenc",
|
||||
"HERO_RECENTLY_ADDED": "Nemrég hozzáadva",
|
||||
"HERO_CONTINUE": "Folytatás",
|
||||
"HERO_WATCH_LIVE": "Élő adás nézése",
|
||||
"HERO_DETAILS": "Részletek",
|
||||
"HERO_SLIDE_POSITION": "{{index}} / {{count}}",
|
||||
"HERO_SHOW_SLIDE": "{{title}} megjelenítése",
|
||||
"HERO_PAUSE": "Diavetítés szüneteltetése",
|
||||
"HERO_PLAY": "Diavetítés lejátszása"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -1563,7 +1563,18 @@
|
||||
"RECENTLY_WATCHED_LIVE_TV": "TV in diretta guardata di recente",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Film e serie preferiti",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "Scade tra {{days}} g",
|
||||
"SOURCE_EXPIRED": "Scaduto"
|
||||
"SOURCE_EXPIRED": "Scaduto",
|
||||
"HERO_LABEL": "In evidenza",
|
||||
"HERO_FAVORITE_CHANNEL": "Canale preferito",
|
||||
"HERO_FAVORITE": "Preferito",
|
||||
"HERO_RECENTLY_ADDED": "Aggiunto di recente",
|
||||
"HERO_CONTINUE": "Riprendi",
|
||||
"HERO_WATCH_LIVE": "Guarda in diretta",
|
||||
"HERO_DETAILS": "Dettagli",
|
||||
"HERO_SLIDE_POSITION": "{{index}} di {{count}}",
|
||||
"HERO_SHOW_SLIDE": "Mostra {{title}}",
|
||||
"HERO_PAUSE": "Metti in pausa la presentazione",
|
||||
"HERO_PLAY": "Avvia la presentazione"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -1563,7 +1563,18 @@
|
||||
"RECENTLY_WATCHED_LIVE_TV": "最近視聴したライブ TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "お気に入りの映画とシリーズ",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "あと{{days}}日で期限切れ",
|
||||
"SOURCE_EXPIRED": "期限切れ"
|
||||
"SOURCE_EXPIRED": "期限切れ",
|
||||
"HERO_LABEL": "おすすめ",
|
||||
"HERO_FAVORITE_CHANNEL": "お気に入りチャンネル",
|
||||
"HERO_FAVORITE": "お気に入り",
|
||||
"HERO_RECENTLY_ADDED": "最近追加",
|
||||
"HERO_CONTINUE": "続きを再生",
|
||||
"HERO_WATCH_LIVE": "ライブで見る",
|
||||
"HERO_DETAILS": "詳細",
|
||||
"HERO_SLIDE_POSITION": "{{index}} / {{count}}",
|
||||
"HERO_SHOW_SLIDE": "{{title}} を表示",
|
||||
"HERO_PAUSE": "スライドショーを一時停止",
|
||||
"HERO_PLAY": "スライドショーを再生"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -1563,7 +1563,18 @@
|
||||
"RECENTLY_WATCHED_LIVE_TV": "최근 시청한 라이브 TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "즐겨찾기한 영화 및 시리즈",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "{{days}}일 후 만료",
|
||||
"SOURCE_EXPIRED": "만료됨"
|
||||
"SOURCE_EXPIRED": "만료됨",
|
||||
"HERO_LABEL": "추천",
|
||||
"HERO_FAVORITE_CHANNEL": "즐겨찾는 채널",
|
||||
"HERO_FAVORITE": "즐겨찾기",
|
||||
"HERO_RECENTLY_ADDED": "최근 추가됨",
|
||||
"HERO_CONTINUE": "이어 보기",
|
||||
"HERO_WATCH_LIVE": "실시간 보기",
|
||||
"HERO_DETAILS": "상세 정보",
|
||||
"HERO_SLIDE_POSITION": "{{index}} / {{count}}",
|
||||
"HERO_SHOW_SLIDE": "{{title}} 보기",
|
||||
"HERO_PAUSE": "슬라이드쇼 일시정지",
|
||||
"HERO_PLAY": "슬라이드쇼 재생"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -1563,7 +1563,18 @@
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Recent bekeken live-tv",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favoriete films en reeksen",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "Verloopt over {{days}} d",
|
||||
"SOURCE_EXPIRED": "Verlopen"
|
||||
"SOURCE_EXPIRED": "Verlopen",
|
||||
"HERO_LABEL": "Uitgelicht",
|
||||
"HERO_FAVORITE_CHANNEL": "Favoriete zender",
|
||||
"HERO_FAVORITE": "Favoriet",
|
||||
"HERO_RECENTLY_ADDED": "Recent toegevoegd",
|
||||
"HERO_CONTINUE": "Verder kijken",
|
||||
"HERO_WATCH_LIVE": "Live kijken",
|
||||
"HERO_DETAILS": "Details",
|
||||
"HERO_SLIDE_POSITION": "{{index}} van {{count}}",
|
||||
"HERO_SHOW_SLIDE": "{{title}} tonen",
|
||||
"HERO_PAUSE": "Diavoorstelling pauzeren",
|
||||
"HERO_PLAY": "Diavoorstelling afspelen"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -1563,7 +1563,18 @@
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Ostatnio oglądana telewizja na żywo",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Ulubione filmy i seriale",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "Wygasa za {{days}} dn.",
|
||||
"SOURCE_EXPIRED": "Wygasło"
|
||||
"SOURCE_EXPIRED": "Wygasło",
|
||||
"HERO_LABEL": "Polecane",
|
||||
"HERO_FAVORITE_CHANNEL": "Ulubiony kanał",
|
||||
"HERO_FAVORITE": "Ulubione",
|
||||
"HERO_RECENTLY_ADDED": "Ostatnio dodane",
|
||||
"HERO_CONTINUE": "Kontynuuj",
|
||||
"HERO_WATCH_LIVE": "Oglądaj na żywo",
|
||||
"HERO_DETAILS": "Szczegóły",
|
||||
"HERO_SLIDE_POSITION": "{{index}} z {{count}}",
|
||||
"HERO_SHOW_SLIDE": "Pokaż {{title}}",
|
||||
"HERO_PAUSE": "Wstrzymaj pokaz",
|
||||
"HERO_PLAY": "Odtwórz pokaz"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -1563,7 +1563,18 @@
|
||||
"RECENTLY_WATCHED_LIVE_TV": "TV ao vivo assistida recentemente",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Filmes e séries favoritos",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "Expira em {{days}} d",
|
||||
"SOURCE_EXPIRED": "Expirada"
|
||||
"SOURCE_EXPIRED": "Expirada",
|
||||
"HERO_LABEL": "Em destaque",
|
||||
"HERO_FAVORITE_CHANNEL": "Canal favorito",
|
||||
"HERO_FAVORITE": "Favorito",
|
||||
"HERO_RECENTLY_ADDED": "Adicionado recentemente",
|
||||
"HERO_CONTINUE": "Continuar",
|
||||
"HERO_WATCH_LIVE": "Assistir ao vivo",
|
||||
"HERO_DETAILS": "Detalhes",
|
||||
"HERO_SLIDE_POSITION": "{{index}} de {{count}}",
|
||||
"HERO_SHOW_SLIDE": "Mostrar {{title}}",
|
||||
"HERO_PAUSE": "Pausar apresentação",
|
||||
"HERO_PLAY": "Reproduzir apresentação"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -1563,7 +1563,18 @@
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Недавно просмотренное Live TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Избранные фильмы и сериалы",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "Истекает через {{days}} дн.",
|
||||
"SOURCE_EXPIRED": "Истёк"
|
||||
"SOURCE_EXPIRED": "Истёк",
|
||||
"HERO_LABEL": "Рекомендуемое",
|
||||
"HERO_FAVORITE_CHANNEL": "Избранный канал",
|
||||
"HERO_FAVORITE": "Избранное",
|
||||
"HERO_RECENTLY_ADDED": "Недавно добавлено",
|
||||
"HERO_CONTINUE": "Продолжить",
|
||||
"HERO_WATCH_LIVE": "Смотреть эфир",
|
||||
"HERO_DETAILS": "Подробнее",
|
||||
"HERO_SLIDE_POSITION": "{{index}} из {{count}}",
|
||||
"HERO_SHOW_SLIDE": "Показать {{title}}",
|
||||
"HERO_PAUSE": "Приостановить показ",
|
||||
"HERO_PLAY": "Возобновить показ"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -1563,7 +1563,18 @@
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Son izlenen canlı TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favori filmler ve diziler",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "{{days}} gün içinde sona erer",
|
||||
"SOURCE_EXPIRED": "Süresi doldu"
|
||||
"SOURCE_EXPIRED": "Süresi doldu",
|
||||
"HERO_LABEL": "Öne çıkanlar",
|
||||
"HERO_FAVORITE_CHANNEL": "Favori kanal",
|
||||
"HERO_FAVORITE": "Favori",
|
||||
"HERO_RECENTLY_ADDED": "Yeni eklendi",
|
||||
"HERO_CONTINUE": "Devam et",
|
||||
"HERO_WATCH_LIVE": "Canlı izle",
|
||||
"HERO_DETAILS": "Ayrıntılar",
|
||||
"HERO_SLIDE_POSITION": "{{index}} / {{count}}",
|
||||
"HERO_SHOW_SLIDE": "{{title}} göster",
|
||||
"HERO_PAUSE": "Slayt gösterisini duraklat",
|
||||
"HERO_PLAY": "Slayt gösterisini oynat"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -1563,7 +1563,18 @@
|
||||
"RECENTLY_WATCHED_LIVE_TV": "最近观看的直播电视",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "收藏的电影和剧集",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "{{days}} 天后到期",
|
||||
"SOURCE_EXPIRED": "已过期"
|
||||
"SOURCE_EXPIRED": "已过期",
|
||||
"HERO_LABEL": "精选",
|
||||
"HERO_FAVORITE_CHANNEL": "收藏频道",
|
||||
"HERO_FAVORITE": "收藏",
|
||||
"HERO_RECENTLY_ADDED": "最近添加",
|
||||
"HERO_CONTINUE": "继续观看",
|
||||
"HERO_WATCH_LIVE": "观看直播",
|
||||
"HERO_DETAILS": "详情",
|
||||
"HERO_SLIDE_POSITION": "第 {{index}} 项,共 {{count}} 项",
|
||||
"HERO_SHOW_SLIDE": "显示 {{title}}",
|
||||
"HERO_PAUSE": "暂停轮播",
|
||||
"HERO_PLAY": "播放轮播"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -1563,7 +1563,18 @@
|
||||
"RECENTLY_WATCHED_LIVE_TV": "最近觀看的直播電視",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "收藏的電影和影集",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "{{days}} 天後到期",
|
||||
"SOURCE_EXPIRED": "已過期"
|
||||
"SOURCE_EXPIRED": "已過期",
|
||||
"HERO_LABEL": "精選",
|
||||
"HERO_FAVORITE_CHANNEL": "收藏頻道",
|
||||
"HERO_FAVORITE": "收藏",
|
||||
"HERO_RECENTLY_ADDED": "最近新增",
|
||||
"HERO_CONTINUE": "繼續觀看",
|
||||
"HERO_WATCH_LIVE": "觀看直播",
|
||||
"HERO_DETAILS": "詳細資訊",
|
||||
"HERO_SLIDE_POSITION": "第 {{index}} 項,共 {{count}} 項",
|
||||
"HERO_SHOW_SLIDE": "顯示 {{title}}",
|
||||
"HERO_PAUSE": "暫停輪播",
|
||||
"HERO_PLAY": "播放輪播"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -733,15 +733,15 @@ since shipped.)
|
||||
does not latch and retries instead. Same gating as trending: TMDB
|
||||
opt-in + Electron DB worker, deferred behind the dashboard's own data.
|
||||
- **Hero extras**: `DashboardHeroTmdbService`
|
||||
(`libs/workspace/dashboard/feature`) patches the hero card with a TMDB
|
||||
backdrop (when the activity row has none), a rating badge and up to two
|
||||
genre chips — resolved through the enrichment facade, so items already
|
||||
opened in a detail view come from the SQLite cache without network.
|
||||
Results are memoized per lookup identity for the session. The hero renders
|
||||
immediately from provider data; extras appear when resolved. Series
|
||||
heroes additionally show the tracked "S{n}·E{n}" badge from the playback
|
||||
position (no TMDB involved); the watch-progress bar is limited to
|
||||
movie/series heroes.
|
||||
(`libs/workspace/dashboard/feature`) patches each movie/series hero slide
|
||||
with a TMDB backdrop (when the activity row has none), a rating badge, up
|
||||
to two genre chips, the overview and the release/first-air year — resolved
|
||||
through the enrichment facade, so items already opened in a detail view
|
||||
come from the SQLite cache without network. Results are memoized per
|
||||
lookup identity for the session. Slides render immediately from provider
|
||||
data; extras appear when resolved and disappear when TMDB is turned off.
|
||||
Series slides additionally show the tracked "S{n}·E{n}" badge from the
|
||||
playback position (no TMDB involved). Live slides never query TMDB.
|
||||
|
||||
The query is built to **match what the detail view searched with**, not
|
||||
just what the card displays. A title alone is weaker identity than the
|
||||
|
||||
@@ -37,7 +37,7 @@ Core implementation:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Hero — Continue Watching (most recent item) │
|
||||
│ Hero — rotating cinematic banner (resume · live · discovery) │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ Continue Watching · See all → │
|
||||
│ [poster][poster][poster][poster] →→ │
|
||||
@@ -73,12 +73,14 @@ Render rules:
|
||||
slow rail does not hide already available content.
|
||||
2. `hasPlaylists() === false` → render `<app-empty-state [type]="'welcome-dashboard'">`
|
||||
full-bleed. All rails and the hero are skipped.
|
||||
3. `hero()` = `globalRecentItems()[0]`. If present, render the hero panel.
|
||||
3. The hero (`lib-dashboard-hero`) renders when it has at least one slide;
|
||||
see [Cinematic Hero](#cinematic-hero). It shows its own skeleton while
|
||||
the first history load runs.
|
||||
4. Each rail is emitted via `@if (cards.length > 0)`. Empty rails are hidden
|
||||
— there is no "empty widget" placeholder.
|
||||
5. The continue-watching hero prefers a stored Xtream `backdrop_url`; when it
|
||||
is missing the UI falls back to a blurred poster treatment instead of
|
||||
showing a flat panel.
|
||||
5. Hero slides prefer a stored `backdrop_url`, then the TMDB backdrop; when
|
||||
both are missing the poster becomes a blurred wash plus key art on the
|
||||
right instead of a flat panel.
|
||||
6. Live favorites are promoted into their own live rail; movie/series
|
||||
favorites render in a separate `Favorite movies & series` rail
|
||||
(`favoriteMoviesAndSeriesCards`, `data-test-id="dashboard-favorite-vod-rail"`,
|
||||
@@ -89,6 +91,47 @@ Render rules:
|
||||
favorites. This avoids first-paint partial counts such as a single Stalker
|
||||
favorite appearing before M3U favorites finish resolving.
|
||||
|
||||
## Cinematic Hero
|
||||
|
||||
`DashboardHeroComponent` renders a full-bleed banner: it cancels the page's
|
||||
`--dashboard-gutter`/top padding and the centred `--dashboard-max-width`
|
||||
(the page host is the `dashboard` inline-size container), keeps
|
||||
`clamp(320px, 42vh, 520px)` so the first rail starts above the fold, and uses
|
||||
`--app-content-bg` as its scrim so it dissolves into the page in both themes.
|
||||
|
||||
Slides (`pickDashboardHeroSources`, at most four, stable order, each title
|
||||
once):
|
||||
|
||||
1. the newest unfinished movie/series (`isPortalPlaybackWatched` rows skip);
|
||||
2. a live channel with a programme on air — the first of
|
||||
`selectDashboardHeroLiveCandidates` (up to three favourites, then two
|
||||
recently watched channels) whose EPG answer has a title;
|
||||
3. one favourite movie/series and one Xtream recently-added title;
|
||||
4. remaining places round-robin over the next items of those lists;
|
||||
5. only when nothing qualifies, the newest history row of any kind (a
|
||||
detail action: it can be a finished title).
|
||||
|
||||
While live candidates exist but none has answered yet, one place stays
|
||||
reserved for the live slide, so its late arrival never evicts a slide the
|
||||
user may be viewing.
|
||||
|
||||
The live candidates are derived and pinned by `DashboardLiveEpgPresenter`
|
||||
itself (XMLTV lookup and portal queue), independent of the live rails, so the
|
||||
slide works with those rails hidden. Actions: a resume slide keeps the resume
|
||||
handoff and adds a detail-only "Details" when a series episode can resume;
|
||||
discovery slides open the detail page; live slides open the channel. TMDB
|
||||
extras (backdrop, rating, genres, overview, year) come from
|
||||
`DashboardHeroTmdbService` per featured title and vanish when TMDB is off.
|
||||
|
||||
Rotation is the active dot's CSS fill animation (8 s); its `animationend`
|
||||
advances. Hover, focus inside the hero and the pause button pause it; an
|
||||
explicit Play clears the hover/focus pause until they re-arm; under
|
||||
`prefers-reduced-motion` nothing auto-advances. The active slide is tracked
|
||||
by id, so a late live slide never moves the user off the current one. Test
|
||||
hooks: `dashboard-hero`, `dashboard-hero-slide` (`data-hero-kind`),
|
||||
`dashboard-hero-dot`, `dashboard-hero-pause`,
|
||||
`dashboard-hero-primary-action`, `dashboard-hero-secondary-action`.
|
||||
|
||||
## Rail Contract
|
||||
|
||||
`DashboardRailComponent` is purely presentational:
|
||||
@@ -109,7 +152,8 @@ Render rules:
|
||||
|
||||
1. `WorkspaceDashboardRailsComponent` injects `DashboardDataService`.
|
||||
2. It derives the dashboard surface via `computed()`:
|
||||
1. `hero` — first item of `globalRecentItems()`.
|
||||
1. The hero slides — built by `DashboardHeroSlidesPresenter`, see
|
||||
[Cinematic Hero](#cinematic-hero).
|
||||
2. `continueWatchingCards` — maps `globalRecentVodItems()` to movie/series
|
||||
cover cards. Portal playback positions are bulk-loaded per playlist so
|
||||
hero and cards can show progress, remaining time, and series season/
|
||||
@@ -161,12 +205,12 @@ Render rules:
|
||||
card counts as visible. Cards that leave the list are reported gone
|
||||
at once.
|
||||
- `DashboardPortalLiveEpgPresenter` (component-provided) unions the
|
||||
visible keys of both rails with the pinned hero key and calls
|
||||
visible keys of both rails with the pinned hero keys and calls
|
||||
`DashboardPortalLiveEpgService.sync()` with exactly those entries —
|
||||
on every change, on the 30 s tick, and on a display-offset change.
|
||||
It is reached through `DashboardLiveEpgPresenter`, which derives the
|
||||
portal rows itself from the enabled rails and pins the hero, so the
|
||||
page component only forwards what a rail can see. The queue lives in
|
||||
portal rows itself from the enabled rails and pins the hero's live
|
||||
candidates, so the page component only forwards what a rail can see. The queue lives in
|
||||
the root service, so leaving the dashboard hands the wanted set back
|
||||
(`sync([])` on destroy); otherwise the queue would keep asking for
|
||||
cards on a page that is gone.
|
||||
|
||||
@@ -10,10 +10,10 @@ contracts on its own.
|
||||
The dashboard renders a surface only when the matching setting is enabled. Data
|
||||
rails also require the underlying data slice to have at least one item.
|
||||
|
||||
- `hero` shows the large top banner for the most recent global item. When that
|
||||
item is a live TV channel, the hero looks up the current XMLTV programme and
|
||||
displays the programme title, time range, and EPG progress bar when data is
|
||||
available.
|
||||
- `hero` shows the full-width cinematic banner that rotates between the
|
||||
newest unfinished title, a favourite (or recent) channel with a programme on
|
||||
air, and favourite / recently added titles. Contract:
|
||||
`docs/architecture/workspace-dashboard.md#cinematic-hero`.
|
||||
- `continueWatching` shows recent movies and series from
|
||||
`DashboardDataService.globalRecentVodItems()` using cover cards with playback
|
||||
progress when a saved resume position is available.
|
||||
|
||||
+383
@@ -0,0 +1,383 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { Subject } from 'rxjs';
|
||||
import type {
|
||||
PlaybackPositionData,
|
||||
PortalActivityItem,
|
||||
PortalAddedItem,
|
||||
PortalFavoriteItem,
|
||||
PortalRecentItem,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { DashboardDataService } from '@iptvnator/workspace/dashboard/data-access';
|
||||
import {
|
||||
DashboardHeroTmdbService,
|
||||
type DashboardHeroTmdbExtras,
|
||||
} from './dashboard-hero-tmdb.service';
|
||||
import { DashboardHeroSlidesPresenter } from './dashboard-hero-slides.presenter';
|
||||
import type { DashboardHeroLiveCandidate } from './dashboard-hero-slides.utils';
|
||||
import { DashboardLiveEpgPresenter } from './dashboard-live-epg.presenter';
|
||||
import type { DashboardLiveEpgDetails } from './dashboard-live-epg.utils';
|
||||
|
||||
const series: PortalRecentItem = {
|
||||
id: 1,
|
||||
title: 'Big Pharma',
|
||||
type: 'movie',
|
||||
watch_kind: 'series',
|
||||
source: 'stalker',
|
||||
playlist_id: 'p1',
|
||||
playlist_name: 'rucolor',
|
||||
category_id: '1',
|
||||
xtream_id: 11,
|
||||
poster_url: 'https://img/pharma-poster.jpg',
|
||||
viewed_at: '2026-09-02',
|
||||
};
|
||||
const watchedMovie: PortalRecentItem = {
|
||||
...series,
|
||||
id: 2,
|
||||
title: 'Finished Film',
|
||||
watch_kind: 'movie',
|
||||
xtream_id: 12,
|
||||
viewed_at: '2026-09-01',
|
||||
};
|
||||
const channel: PortalFavoriteItem = {
|
||||
id: 3,
|
||||
title: 'Match TV',
|
||||
type: 'live',
|
||||
source: 'xtream',
|
||||
playlist_id: 'p2',
|
||||
category_id: '5',
|
||||
xtream_id: 33,
|
||||
poster_url: 'https://img/match-logo.png',
|
||||
added_at: '2026-08-01',
|
||||
};
|
||||
const favoriteFilm: PortalFavoriteItem = {
|
||||
id: 4,
|
||||
title: 'Burnley',
|
||||
type: 'series',
|
||||
source: 'xtream',
|
||||
playlist_id: 'p2',
|
||||
playlist_name: 'http://user:secret@4kgood.org:8080',
|
||||
category_id: '9',
|
||||
xtream_id: 44,
|
||||
release_year: 2023,
|
||||
added_at: '2026-08-02',
|
||||
};
|
||||
const import1: PortalAddedItem = {
|
||||
id: 5,
|
||||
title: 'Parallel Stories',
|
||||
type: 'movie',
|
||||
source: 'xtream',
|
||||
playlist_id: 'p3',
|
||||
category_id: '2',
|
||||
xtream_id: 55,
|
||||
backdrop_url: 'https://img/parallel-wide.jpg',
|
||||
added_at: '2026-09-03',
|
||||
};
|
||||
|
||||
const onAir: DashboardLiveEpgDetails = {
|
||||
nowPlayingTitle: 'Football: farewell match',
|
||||
nowPlayingTimeRange: '18:50 – 20:55',
|
||||
nowPlayingProgress: 35,
|
||||
nowPlayingDescription: 'Live from Moscow.',
|
||||
nowPlayingCategory: 'Sport',
|
||||
};
|
||||
|
||||
describe('DashboardHeroSlidesPresenter', () => {
|
||||
let recentItems: ReturnType<typeof signal<PortalRecentItem[]>>;
|
||||
let favorites: ReturnType<typeof signal<PortalFavoriteItem[]>>;
|
||||
let addedItems: ReturnType<typeof signal<PortalAddedItem[]>>;
|
||||
let candidates: ReturnType<typeof signal<DashboardHeroLiveCandidate[]>>;
|
||||
let liveDetails: jest.Mock;
|
||||
let tmdbEnabled: ReturnType<typeof signal<boolean>>;
|
||||
let tmdbLanguage: ReturnType<typeof signal<string>>;
|
||||
let getExtras: jest.Mock;
|
||||
const positions = new Map<string | number, PlaybackPositionData>([
|
||||
[
|
||||
1,
|
||||
{
|
||||
contentXtreamId: 111,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 11,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 3,
|
||||
positionSeconds: 600,
|
||||
durationSeconds: 1800,
|
||||
},
|
||||
],
|
||||
[
|
||||
2,
|
||||
{
|
||||
contentXtreamId: 12,
|
||||
contentType: 'vod',
|
||||
positionSeconds: 5900,
|
||||
durationSeconds: 6000,
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
function create(): DashboardHeroSlidesPresenter {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
DashboardHeroSlidesPresenter,
|
||||
{
|
||||
provide: DashboardDataService,
|
||||
useValue: {
|
||||
globalRecentLoading: signal(false),
|
||||
globalRecentItems: recentItems,
|
||||
globalRecentVodItems: () =>
|
||||
recentItems().filter((i) => i.type !== 'live'),
|
||||
globalFavoriteItems: favorites,
|
||||
xtreamRecentlyAddedItems: addedItems,
|
||||
getPlaybackPositionForItem: (
|
||||
item: PortalActivityItem
|
||||
) => positions.get(item.id) ?? null,
|
||||
getRecentItemLink: (item: PortalActivityItem) => [
|
||||
'/recent',
|
||||
String(item.id),
|
||||
],
|
||||
getRecentItemNavigationState: () => ({ resume: true }),
|
||||
getRecentItemDetailNavigationState: () => ({
|
||||
resume: false,
|
||||
}),
|
||||
getRecentItemResumeNavigation: (
|
||||
item: PortalActivityItem
|
||||
) => (item.id === 1 ? { link: [], state: {} } : null),
|
||||
getGlobalFavoriteLink: (item: PortalActivityItem) => [
|
||||
'/favorite',
|
||||
String(item.id),
|
||||
],
|
||||
getGlobalFavoriteNavigationState: () => ({ fav: true }),
|
||||
getRecentlyAddedLink: (item: PortalActivityItem) => [
|
||||
'/added',
|
||||
String(item.id),
|
||||
],
|
||||
getRecentlyAddedNavigationState: () => ({
|
||||
added: true,
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: DashboardLiveEpgPresenter,
|
||||
useValue: {
|
||||
heroLiveCandidates: candidates,
|
||||
heroDetailsFor: liveDetails,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: DashboardHeroTmdbService,
|
||||
useValue: {
|
||||
isEnabled: () => tmdbEnabled(),
|
||||
keyFor: (item: PortalActivityItem) =>
|
||||
`${tmdbLanguage()}//${item.title}`,
|
||||
getExtras,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
onLangChange: new Subject(),
|
||||
instant: (key: string, params?: object) =>
|
||||
params ? `${key} ${JSON.stringify(params)}` : key,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
return TestBed.inject(DashboardHeroSlidesPresenter);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
recentItems = signal([series, watchedMovie]);
|
||||
favorites = signal([channel, favoriteFilm]);
|
||||
addedItems = signal([import1]);
|
||||
candidates = signal([{ origin: 'favorite', item: channel }]);
|
||||
liveDetails = jest.fn(() => onAir);
|
||||
tmdbEnabled = signal(false);
|
||||
tmdbLanguage = signal('en-US');
|
||||
getExtras = jest.fn().mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it('builds the rotation from resume, live, favourite and import slides', () => {
|
||||
const slides = create().slides();
|
||||
|
||||
expect(slides.map((slide) => [slide.kind, slide.title])).toEqual([
|
||||
['continue', 'Big Pharma'],
|
||||
['live', 'Match TV'],
|
||||
['favorite', 'Burnley'],
|
||||
['added', 'Parallel Stories'],
|
||||
]);
|
||||
// A (nearly) finished film is not something to continue.
|
||||
expect(slides.some((slide) => slide.title === 'Finished Film')).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('resumes an unfinished series and offers a detail-only way in', () => {
|
||||
const [resume] = create().slides();
|
||||
|
||||
expect(resume).toMatchObject({
|
||||
typeLabelKey: 'WORKSPACE.DASHBOARD.TYPE_SERIES',
|
||||
reasonLabelKey: 'WORKSPACE.DASHBOARD.CONTINUE_WATCHING',
|
||||
episodeBadge:
|
||||
'WORKSPACE.DASHBOARD.SEASON_EPISODE_BADGE {"season":1,"episode":3}',
|
||||
source: 'rucolor',
|
||||
progress: 33,
|
||||
// No 16:9 backdrop: the poster becomes the blurred stage.
|
||||
backdropSource: 'poster',
|
||||
primaryAction: {
|
||||
labelKey: 'WORKSPACE.DASHBOARD.HERO_CONTINUE',
|
||||
link: ['/recent', '1'],
|
||||
state: { resume: true },
|
||||
remainingLabel: {
|
||||
key: 'WORKSPACE.DASHBOARD.REMAINING_MINUTES',
|
||||
params: { minutes: 20 },
|
||||
},
|
||||
},
|
||||
secondaryAction: {
|
||||
labelKey: 'WORKSPACE.DASHBOARD.HERO_DETAILS',
|
||||
state: { resume: false },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a favourite channel with its programme on air now', () => {
|
||||
const live = create().slides()[1];
|
||||
|
||||
expect(live).toMatchObject({
|
||||
typeLabelKey: 'WORKSPACE.DASHBOARD.TYPE_LIVE',
|
||||
reasonLabelKey: 'WORKSPACE.DASHBOARD.HERO_FAVORITE_CHANNEL',
|
||||
programmeTitle: 'Football: farewell match',
|
||||
description: 'Live from Moscow.',
|
||||
category: 'Sport',
|
||||
timeRange: '18:50 – 20:55',
|
||||
progress: 35,
|
||||
primaryAction: {
|
||||
labelKey: 'WORKSPACE.DASHBOARD.HERO_WATCH_LIVE',
|
||||
link: ['/favorite', '3'],
|
||||
},
|
||||
secondaryAction: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves the live slide out until a candidate has a programme on air', () => {
|
||||
liveDetails.mockReturnValue(null);
|
||||
const presenter = create();
|
||||
|
||||
expect(presenter.slides().map((slide) => slide.kind)).not.toContain(
|
||||
'live'
|
||||
);
|
||||
});
|
||||
|
||||
it('opens discovery slides on their detail page, with a safe source label', () => {
|
||||
const slides = create().slides();
|
||||
|
||||
// The stored name is a URL with credentials: only its host shows.
|
||||
expect(slides[2].source).toBe('4kgood.org:8080');
|
||||
expect(slides[2]).toMatchObject({
|
||||
year: 2023,
|
||||
primaryAction: {
|
||||
labelKey: 'WORKSPACE.DASHBOARD.HERO_DETAILS',
|
||||
link: ['/favorite', '4'],
|
||||
},
|
||||
secondaryAction: null,
|
||||
});
|
||||
expect(slides[3]).toMatchObject({
|
||||
backdropSource: 'backdrop',
|
||||
primaryAction: { link: ['/added', '5'], state: { added: true } },
|
||||
});
|
||||
});
|
||||
|
||||
it('patches TMDB extras in, and drops them when TMDB is switched off', async () => {
|
||||
tmdbEnabled.set(true);
|
||||
const extras: DashboardHeroTmdbExtras = {
|
||||
backdropUrl: 'https://tmdb/pharma-wide.jpg',
|
||||
rating: '7.5',
|
||||
genres: ['Drama'],
|
||||
overview: 'A pharmacist enters big business.',
|
||||
year: 2024,
|
||||
};
|
||||
getExtras.mockImplementation((item: PortalActivityItem) =>
|
||||
Promise.resolve(item.title === 'Big Pharma' ? extras : null)
|
||||
);
|
||||
const presenter = create();
|
||||
TestBed.tick();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(getExtras).toHaveBeenCalledTimes(3);
|
||||
expect(presenter.slides()[0]).toMatchObject({
|
||||
backdropUrl: 'https://tmdb/pharma-wide.jpg',
|
||||
backdropSource: 'backdrop',
|
||||
rating: '7.5',
|
||||
genres: ['Drama'],
|
||||
description: 'A pharmacist enters big business.',
|
||||
year: 2024,
|
||||
});
|
||||
|
||||
tmdbEnabled.set(false);
|
||||
expect(presenter.slides()[0]).toMatchObject({
|
||||
backdropSource: 'poster',
|
||||
rating: null,
|
||||
description: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('loads the extras again when the TMDB language changes', async () => {
|
||||
tmdbEnabled.set(true);
|
||||
getExtras.mockImplementation((item: PortalActivityItem) =>
|
||||
Promise.resolve(
|
||||
item.title === 'Big Pharma'
|
||||
? {
|
||||
backdropUrl: null,
|
||||
rating: '7.5',
|
||||
genres: [
|
||||
tmdbLanguage() === 'en-US' ? 'Drama' : 'Драма',
|
||||
],
|
||||
overview: `plot in ${tmdbLanguage()}`,
|
||||
year: 2024,
|
||||
}
|
||||
: null
|
||||
)
|
||||
);
|
||||
const presenter = create();
|
||||
TestBed.tick();
|
||||
await Promise.resolve();
|
||||
expect(presenter.slides()[0].description).toBe('plot in en-US');
|
||||
|
||||
tmdbLanguage.set('ru-RU');
|
||||
TestBed.tick();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(getExtras).toHaveBeenCalledTimes(6);
|
||||
expect(presenter.slides()[0]).toMatchObject({
|
||||
description: 'plot in ru-RU',
|
||||
genres: ['Драма'],
|
||||
});
|
||||
});
|
||||
|
||||
it('opens a finished fallback title on its details, never "Continue"', () => {
|
||||
recentItems.set([watchedMovie]);
|
||||
favorites.set([]);
|
||||
addedItems.set([]);
|
||||
candidates.set([]);
|
||||
const [fallback] = create().slides();
|
||||
|
||||
expect(fallback).toMatchObject({
|
||||
kind: 'recent',
|
||||
primaryAction: {
|
||||
labelKey: 'WORKSPACE.DASHBOARD.HERO_DETAILS',
|
||||
state: { resume: false },
|
||||
},
|
||||
secondaryAction: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the generated stage when an image fails to load', () => {
|
||||
const presenter = create();
|
||||
presenter.markImageFailed('https://img/pharma-poster.jpg');
|
||||
|
||||
expect(presenter.slides()[0]).toMatchObject({
|
||||
backdropUrl: undefined,
|
||||
backdropSource: 'fallback',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,372 @@
|
||||
import {
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
Injectable,
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { startWith } from 'rxjs';
|
||||
import { isPortalPlaybackWatched } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
playlistDisplayLabel,
|
||||
resolvePortalActivityWatchKind,
|
||||
type PlaybackPositionData,
|
||||
type PortalActivityItem,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { DashboardDataService } from '@iptvnator/workspace/dashboard/data-access';
|
||||
import {
|
||||
DashboardHeroTmdbService,
|
||||
type DashboardHeroTmdbExtras,
|
||||
} from './dashboard-hero-tmdb.service';
|
||||
import {
|
||||
dashboardHeroHue,
|
||||
resolveDashboardHeroArtwork,
|
||||
type DashboardHeroAction,
|
||||
type DashboardHeroSlide,
|
||||
} from './dashboard-hero.utils';
|
||||
import {
|
||||
dashboardHeroItemKey,
|
||||
pickDashboardHeroSources,
|
||||
type DashboardHeroSource,
|
||||
} from './dashboard-hero-slides.utils';
|
||||
import { DashboardLiveEpgPresenter } from './dashboard-live-epg.presenter';
|
||||
import type { DashboardLiveEpgDetails } from './dashboard-live-epg.utils';
|
||||
import {
|
||||
buildDashboardEpisodeBadge,
|
||||
formatRemainingLabel,
|
||||
playbackProgressPercent,
|
||||
} from './dashboard-playback.utils';
|
||||
|
||||
const TYPE_LABEL_KEYS = {
|
||||
live: 'WORKSPACE.DASHBOARD.TYPE_LIVE',
|
||||
movie: 'WORKSPACE.DASHBOARD.TYPE_MOVIE',
|
||||
series: 'WORKSPACE.DASHBOARD.TYPE_SERIES',
|
||||
} as const;
|
||||
|
||||
const PROVIDER_LABEL_KEYS = {
|
||||
xtream: 'WORKSPACE.DASHBOARD.XTREAM',
|
||||
stalker: 'WORKSPACE.DASHBOARD.STALKER',
|
||||
m3u: 'WORKSPACE.DASHBOARD.M3U',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Builds the cinematic hero's rotation slides from the dashboard data:
|
||||
* which titles are featured (`pickDashboardHeroSources`), their artwork,
|
||||
* TMDB extras, playback progress, live programme and actions.
|
||||
*
|
||||
* Component-provided next to the hero; the live programme answers come from
|
||||
* the page's `DashboardLiveEpgPresenter`, which already pins the hero's live
|
||||
* candidates. TMDB extras are fetched per featured title after first paint,
|
||||
* memoized by `DashboardHeroTmdbService`, and vanish immediately when the
|
||||
* user opts out of TMDB mid-session.
|
||||
*/
|
||||
@Injectable()
|
||||
export class DashboardHeroSlidesPresenter {
|
||||
private readonly data = inject(DashboardDataService);
|
||||
private readonly liveEpg = inject(DashboardLiveEpgPresenter);
|
||||
private readonly heroTmdb = inject(DashboardHeroTmdbService);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly languageTick = toSignal(
|
||||
this.translate.onLangChange.pipe(startWith(null)),
|
||||
{ initialValue: null }
|
||||
);
|
||||
|
||||
private readonly failedImages = signal<Record<string, true>>({});
|
||||
private readonly tmdbExtras = signal<
|
||||
ReadonlyMap<string, DashboardHeroTmdbExtras | null>
|
||||
>(new Map());
|
||||
private readonly requestedTmdbKeys = new Set<string>();
|
||||
|
||||
/** First history load still running and nothing to feature yet. */
|
||||
readonly loading = computed(
|
||||
() => this.data.globalRecentLoading() && this.slides().length === 0
|
||||
);
|
||||
|
||||
/** The first candidate channel with a programme on air right now. */
|
||||
private readonly liveSlide = computed(() => {
|
||||
for (const candidate of this.liveEpg.heroLiveCandidates()) {
|
||||
const details = this.liveEpg.heroDetailsFor(candidate.item);
|
||||
if (details?.nowPlayingTitle) {
|
||||
return { candidate, details };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
private readonly sources = computed(() =>
|
||||
pickDashboardHeroSources({
|
||||
continueItems: this.data
|
||||
.globalRecentVodItems()
|
||||
.filter(
|
||||
(item) =>
|
||||
!isPortalPlaybackWatched(
|
||||
this.data.getPlaybackPositionForItem(item)
|
||||
)
|
||||
),
|
||||
live: this.liveSlide()?.candidate ?? null,
|
||||
reserveLive: this.liveEpg.heroLiveCandidates().length > 0,
|
||||
favorites: this.data
|
||||
.globalFavoriteItems()
|
||||
.filter(
|
||||
(item) => item.type === 'movie' || item.type === 'series'
|
||||
),
|
||||
recentlyAdded: this.data.xtreamRecentlyAddedItems(),
|
||||
mostRecent: this.data.globalRecentItems()[0] ?? null,
|
||||
})
|
||||
);
|
||||
|
||||
readonly slides = computed<DashboardHeroSlide[]>(() => {
|
||||
this.languageTick();
|
||||
const liveDetails = this.liveSlide()?.details ?? null;
|
||||
return this.sources().map((source) =>
|
||||
this.toSlide(source, liveDetails)
|
||||
);
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
if (!this.heroTmdb.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
// Keys read here, tracked: they carry the TMDB language, so a
|
||||
// language change loads the localized overview and genres.
|
||||
const requests = this.sources()
|
||||
.map((source) => source.item)
|
||||
.filter((item) => item.type !== 'live')
|
||||
.map((item) => ({ item, key: this.heroTmdb.keyFor(item) }));
|
||||
untracked(() =>
|
||||
requests.forEach(({ item, key }) =>
|
||||
this.loadTmdbExtras(item, key)
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
markImageFailed(url: string): void {
|
||||
this.failedImages.update((state) =>
|
||||
state[url] ? state : { ...state, [url]: true }
|
||||
);
|
||||
}
|
||||
|
||||
private loadTmdbExtras(item: PortalActivityItem, key: string): void {
|
||||
if (this.requestedTmdbKeys.has(key)) {
|
||||
return;
|
||||
}
|
||||
this.requestedTmdbKeys.add(key);
|
||||
void this.heroTmdb.getExtras(item).then((extras) => {
|
||||
this.tmdbExtras.update((state) => new Map(state).set(key, extras));
|
||||
});
|
||||
}
|
||||
|
||||
private toSlide(
|
||||
source: DashboardHeroSource,
|
||||
liveDetails: DashboardLiveEpgDetails | null
|
||||
): DashboardHeroSlide {
|
||||
const item = source.item;
|
||||
const isLive = item.type === 'live';
|
||||
// Single reactive read, gated on the TMDB opt-in
|
||||
const extras =
|
||||
!isLive && this.heroTmdb.isEnabled()
|
||||
? (this.tmdbExtras().get(this.heroTmdb.keyFor(item)) ?? null)
|
||||
: null;
|
||||
const details =
|
||||
source.kind === 'live'
|
||||
? liveDetails
|
||||
: isLive
|
||||
? this.liveEpg.heroDetailsFor(item)
|
||||
: null;
|
||||
const position = isLive
|
||||
? null
|
||||
: this.data.getPlaybackPositionForItem(item);
|
||||
const artwork = resolveDashboardHeroArtwork(
|
||||
{
|
||||
backdropUrl: item.backdrop_url || extras?.backdropUrl || null,
|
||||
posterUrl: item.poster_url,
|
||||
title: item.title,
|
||||
},
|
||||
this.failedImages()
|
||||
);
|
||||
|
||||
return {
|
||||
...artwork,
|
||||
id: `${source.kind}:${dashboardHeroItemKey(item)}`,
|
||||
kind: source.kind,
|
||||
contentType: item.type,
|
||||
title: item.title,
|
||||
// Watch kind: a Stalker embedded-VOD show routes as a movie
|
||||
typeLabelKey:
|
||||
TYPE_LABEL_KEYS[
|
||||
isLive
|
||||
? 'live'
|
||||
: (resolvePortalActivityWatchKind(item) ?? item.type)
|
||||
],
|
||||
reasonLabelKey: this.reasonLabelKey(source),
|
||||
episodeBadge: buildDashboardEpisodeBadge(
|
||||
item,
|
||||
position,
|
||||
(key, params) => this.translate.instant(key, params)
|
||||
),
|
||||
rating: extras?.rating ?? null,
|
||||
genres: extras?.genres ?? [],
|
||||
year: extras?.year ?? item.release_year ?? null,
|
||||
source: playlistDisplayLabel(
|
||||
item.playlist_name,
|
||||
this.translate.instant(
|
||||
item.source
|
||||
? PROVIDER_LABEL_KEYS[item.source]
|
||||
: 'WORKSPACE.DASHBOARD.PROVIDER'
|
||||
)
|
||||
),
|
||||
programmeTitle: details?.nowPlayingTitle ?? null,
|
||||
category: details?.nowPlayingCategory ?? null,
|
||||
timeRange: details?.nowPlayingTimeRange ?? null,
|
||||
description: isLive
|
||||
? (details?.nowPlayingDescription ?? null)
|
||||
: (extras?.overview ?? null),
|
||||
progress: isLive
|
||||
? (details?.nowPlayingProgress ?? null)
|
||||
: playbackProgressPercent(position),
|
||||
accentHue: dashboardHeroHue(item.title),
|
||||
...this.actionsFor(source, position),
|
||||
};
|
||||
}
|
||||
|
||||
private reasonLabelKey(source: DashboardHeroSource): string {
|
||||
switch (source.kind) {
|
||||
case 'continue':
|
||||
return 'WORKSPACE.DASHBOARD.CONTINUE_WATCHING';
|
||||
case 'live':
|
||||
return source.origin === 'favorite'
|
||||
? 'WORKSPACE.DASHBOARD.HERO_FAVORITE_CHANNEL'
|
||||
: 'WORKSPACE.DASHBOARD.RECENTLY_WATCHED';
|
||||
case 'favorite':
|
||||
return 'WORKSPACE.DASHBOARD.HERO_FAVORITE';
|
||||
case 'added':
|
||||
return 'WORKSPACE.DASHBOARD.HERO_RECENTLY_ADDED';
|
||||
case 'recent':
|
||||
return 'WORKSPACE.DASHBOARD.RECENTLY_WATCHED';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume slides keep the hero's resume handoff (a saved series episode
|
||||
* auto-plays) and offer "Details" as the detail-only way in; discovery
|
||||
* and fallback slides open the detail page; live slides open the channel.
|
||||
*/
|
||||
private actionsFor(
|
||||
source: DashboardHeroSource,
|
||||
position: PlaybackPositionData | null
|
||||
): Pick<DashboardHeroSlide, 'primaryAction' | 'secondaryAction'> {
|
||||
switch (source.kind) {
|
||||
case 'live':
|
||||
return {
|
||||
primaryAction:
|
||||
source.origin === 'favorite'
|
||||
? watchLiveAction(
|
||||
this.data.getGlobalFavoriteLink(source.item),
|
||||
this.data.getGlobalFavoriteNavigationState(
|
||||
source.item
|
||||
)
|
||||
)
|
||||
: watchLiveAction(
|
||||
this.data.getRecentItemLink(source.item),
|
||||
this.data.getRecentItemNavigationState(
|
||||
source.item
|
||||
)
|
||||
),
|
||||
secondaryAction: null,
|
||||
};
|
||||
case 'favorite':
|
||||
return {
|
||||
primaryAction: detailsAction(
|
||||
this.data.getGlobalFavoriteLink(source.item),
|
||||
this.data.getGlobalFavoriteNavigationState(source.item)
|
||||
),
|
||||
secondaryAction: null,
|
||||
};
|
||||
case 'added':
|
||||
return {
|
||||
primaryAction: detailsAction(
|
||||
this.data.getRecentlyAddedLink(source.item),
|
||||
this.data.getRecentlyAddedNavigationState(source.item)
|
||||
),
|
||||
secondaryAction: null,
|
||||
};
|
||||
case 'continue':
|
||||
case 'recent': {
|
||||
const item = source.item;
|
||||
const link = this.data.getRecentItemLink(item);
|
||||
const state = this.data.getRecentItemNavigationState(item);
|
||||
if (item.type === 'live') {
|
||||
return {
|
||||
primaryAction: watchLiveAction(link, state),
|
||||
secondaryAction: null,
|
||||
};
|
||||
}
|
||||
// The fallback row can be a finished title (the resume
|
||||
// candidates skip those): nothing to continue, open details.
|
||||
if (source.kind === 'recent') {
|
||||
return {
|
||||
primaryAction: detailsAction(
|
||||
link,
|
||||
this.data.getRecentItemDetailNavigationState(item)
|
||||
),
|
||||
secondaryAction: null,
|
||||
};
|
||||
}
|
||||
const canResume =
|
||||
this.data.getRecentItemResumeNavigation(item) !== null;
|
||||
return {
|
||||
primaryAction: {
|
||||
labelKey: 'WORKSPACE.DASHBOARD.HERO_CONTINUE',
|
||||
icon: 'play_arrow',
|
||||
link,
|
||||
state,
|
||||
remainingLabel: formatRemainingLabel(position),
|
||||
testId: 'dashboard-hero-primary-action',
|
||||
},
|
||||
secondaryAction: canResume
|
||||
? {
|
||||
...detailsAction(
|
||||
link,
|
||||
this.data.getRecentItemDetailNavigationState(
|
||||
item
|
||||
)
|
||||
),
|
||||
testId: 'dashboard-hero-secondary-action',
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function watchLiveAction(
|
||||
link: string[],
|
||||
state: Record<string, unknown> | undefined
|
||||
): DashboardHeroAction {
|
||||
return {
|
||||
labelKey: 'WORKSPACE.DASHBOARD.HERO_WATCH_LIVE',
|
||||
icon: 'play_arrow',
|
||||
link,
|
||||
state,
|
||||
testId: 'dashboard-hero-primary-action',
|
||||
};
|
||||
}
|
||||
|
||||
function detailsAction(
|
||||
link: string[],
|
||||
state: Record<string, unknown> | undefined
|
||||
): DashboardHeroAction {
|
||||
return {
|
||||
labelKey: 'WORKSPACE.DASHBOARD.HERO_DETAILS',
|
||||
icon: 'info',
|
||||
link,
|
||||
state,
|
||||
testId: 'dashboard-hero-primary-action',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import type {
|
||||
PortalAddedItem,
|
||||
PortalFavoriteItem,
|
||||
PortalRecentItem,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
dashboardHeroItemKey,
|
||||
HERO_SLIDE_LIMIT,
|
||||
pickDashboardHeroSources,
|
||||
selectDashboardHeroLiveCandidates,
|
||||
type DashboardHeroSourceInput,
|
||||
} from './dashboard-hero-slides.utils';
|
||||
|
||||
const base = (
|
||||
id: number,
|
||||
type: 'live' | 'movie' | 'series',
|
||||
title: string
|
||||
) => ({
|
||||
id,
|
||||
title,
|
||||
type,
|
||||
playlist_id: 'p',
|
||||
category_id: '1',
|
||||
xtream_id: id,
|
||||
source: 'xtream' as const,
|
||||
});
|
||||
const recent = (
|
||||
id: number,
|
||||
type: 'live' | 'movie' | 'series' = 'movie'
|
||||
): PortalRecentItem => ({
|
||||
...base(id, type, `recent-${id}`),
|
||||
viewed_at: '2026-09-01',
|
||||
});
|
||||
const favorite = (
|
||||
id: number,
|
||||
type: 'live' | 'movie' | 'series' = 'movie'
|
||||
): PortalFavoriteItem => ({
|
||||
...base(id, type, `favorite-${id}`),
|
||||
added_at: '2026-09-01',
|
||||
});
|
||||
const added = (id: number): PortalAddedItem => ({
|
||||
...base(id, 'movie', `added-${id}`),
|
||||
added_at: '2026-09-01',
|
||||
});
|
||||
|
||||
const input = (
|
||||
overrides: Partial<DashboardHeroSourceInput>
|
||||
): DashboardHeroSourceInput => ({
|
||||
continueItems: [],
|
||||
live: null,
|
||||
reserveLive: false,
|
||||
favorites: [],
|
||||
recentlyAdded: [],
|
||||
mostRecent: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const summary = (sources: ReturnType<typeof pickDashboardHeroSources>) =>
|
||||
sources.map((source) => `${source.kind}:${source.item.title}`);
|
||||
|
||||
describe('pickDashboardHeroSources', () => {
|
||||
it('leads with the title to resume, then live, a favourite and an import', () => {
|
||||
const live = favorite(50, 'live');
|
||||
expect(
|
||||
summary(
|
||||
pickDashboardHeroSources(
|
||||
input({
|
||||
continueItems: [recent(1), recent(2)],
|
||||
live: { origin: 'favorite', item: live },
|
||||
favorites: [favorite(10), favorite(11)],
|
||||
recentlyAdded: [added(20), added(21)],
|
||||
})
|
||||
)
|
||||
)
|
||||
).toEqual([
|
||||
'continue:recent-1',
|
||||
'live:favorite-50',
|
||||
'favorite:favorite-10',
|
||||
'added:added-20',
|
||||
]);
|
||||
});
|
||||
|
||||
it('fills the remaining places round-robin and never exceeds the limit', () => {
|
||||
const sources = pickDashboardHeroSources(
|
||||
input({
|
||||
continueItems: [recent(1), recent(2), recent(3)],
|
||||
favorites: [favorite(10)],
|
||||
})
|
||||
);
|
||||
|
||||
expect(summary(sources)).toEqual([
|
||||
'continue:recent-1',
|
||||
'favorite:favorite-10',
|
||||
'continue:recent-2',
|
||||
'continue:recent-3',
|
||||
]);
|
||||
expect(sources.length).toBeLessThanOrEqual(HERO_SLIDE_LIMIT);
|
||||
});
|
||||
|
||||
it('features a title once even when it is both unfinished and a favourite', () => {
|
||||
const shared = recent(1);
|
||||
expect(
|
||||
summary(
|
||||
pickDashboardHeroSources(
|
||||
input({
|
||||
continueItems: [shared],
|
||||
favorites: [{ ...shared, added_at: 'x' }, favorite(10)],
|
||||
})
|
||||
)
|
||||
)
|
||||
).toEqual(['continue:recent-1', 'favorite:favorite-10']);
|
||||
});
|
||||
|
||||
it('keeps the order stable when the live slide arrives late', () => {
|
||||
const without = summary(
|
||||
pickDashboardHeroSources(
|
||||
input({
|
||||
continueItems: [recent(1)],
|
||||
favorites: [favorite(10)],
|
||||
})
|
||||
)
|
||||
);
|
||||
const withLive = summary(
|
||||
pickDashboardHeroSources(
|
||||
input({
|
||||
continueItems: [recent(1)],
|
||||
live: { origin: 'recent', item: recent(60, 'live') },
|
||||
favorites: [favorite(10)],
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
expect(without).toEqual(['continue:recent-1', 'favorite:favorite-10']);
|
||||
expect(withLive).toEqual([
|
||||
'continue:recent-1',
|
||||
'live:recent-60',
|
||||
'favorite:favorite-10',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a place for a pending live slide so its arrival evicts nothing', () => {
|
||||
const lists = {
|
||||
continueItems: [recent(1), recent(2)],
|
||||
favorites: [favorite(10)],
|
||||
recentlyAdded: [added(20)],
|
||||
};
|
||||
const pending = summary(
|
||||
pickDashboardHeroSources(input({ ...lists, reserveLive: true }))
|
||||
);
|
||||
const arrived = summary(
|
||||
pickDashboardHeroSources(
|
||||
input({
|
||||
...lists,
|
||||
reserveLive: true,
|
||||
live: { origin: 'favorite', item: favorite(50, 'live') },
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
expect(pending).toEqual([
|
||||
'continue:recent-1',
|
||||
'favorite:favorite-10',
|
||||
'added:added-20',
|
||||
]);
|
||||
// Every slide shown before the live answer is still there after it.
|
||||
expect(arrived).toEqual([
|
||||
'continue:recent-1',
|
||||
'live:favorite-50',
|
||||
'favorite:favorite-10',
|
||||
'added:added-20',
|
||||
]);
|
||||
expect(
|
||||
summary(pickDashboardHeroSources(input({ ...lists })))
|
||||
).toHaveLength(HERO_SLIDE_LIMIT);
|
||||
});
|
||||
|
||||
it('falls back to the newest history row only when nothing else qualifies', () => {
|
||||
const channel = recent(70, 'live');
|
||||
expect(
|
||||
summary(pickDashboardHeroSources(input({ mostRecent: channel })))
|
||||
).toEqual(['recent:recent-70']);
|
||||
expect(
|
||||
summary(
|
||||
pickDashboardHeroSources(
|
||||
input({ mostRecent: channel, favorites: [favorite(10)] })
|
||||
)
|
||||
)
|
||||
).toEqual(['favorite:favorite-10']);
|
||||
expect(pickDashboardHeroSources(input({}))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectDashboardHeroLiveCandidates', () => {
|
||||
it('prefers favourites, adds recent channels and skips duplicates', () => {
|
||||
const candidates = selectDashboardHeroLiveCandidates(
|
||||
[
|
||||
favorite(1, 'live'),
|
||||
favorite(2, 'live'),
|
||||
favorite(3, 'live'),
|
||||
favorite(4, 'live'),
|
||||
],
|
||||
[recent(2, 'live'), recent(5, 'live'), recent(6, 'live')]
|
||||
);
|
||||
|
||||
expect(
|
||||
candidates.map(({ origin, item }) => `${origin}:${item.id}`)
|
||||
).toEqual(['favorite:1', 'favorite:2', 'favorite:3', 'recent:5']);
|
||||
});
|
||||
|
||||
it('ignores non-live rows', () => {
|
||||
expect(
|
||||
selectDashboardHeroLiveCandidates([favorite(1, 'movie')], [])
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dashboardHeroItemKey', () => {
|
||||
it('identifies a title across the lists it can come from', () => {
|
||||
expect(dashboardHeroItemKey(recent(1))).toBe(
|
||||
dashboardHeroItemKey(favorite(1))
|
||||
);
|
||||
expect(dashboardHeroItemKey(recent(1))).not.toBe(
|
||||
dashboardHeroItemKey(recent(1, 'series'))
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import type {
|
||||
PortalActivityItem,
|
||||
PortalAddedItem,
|
||||
PortalFavoriteItem,
|
||||
PortalRecentItem,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
/** Slides the cinematic hero rotates through, at most. */
|
||||
export const HERO_SLIDE_LIMIT = 4;
|
||||
|
||||
/** Auto-advance interval; the active dot fills over the same duration. */
|
||||
export const HERO_ROTATION_MS = 8_000;
|
||||
|
||||
const HERO_LIVE_FAVORITE_CANDIDATES = 3;
|
||||
const HERO_LIVE_RECENT_CANDIDATES = 2;
|
||||
|
||||
/**
|
||||
* Why a title is on the hero, which decides its eyebrow and actions:
|
||||
* - `continue` — unfinished movie/series from the recent history
|
||||
* - `live` — a favourite (or recently watched) channel with a programme on air
|
||||
* - `favorite` / `added` — discovery slides from favourites and Xtream imports
|
||||
* - `recent` — last-resort fallback: the most recent item of any kind, so a
|
||||
* history with only guide-less live channels still gets a hero
|
||||
*/
|
||||
export type DashboardHeroSlideKind =
|
||||
'continue' | 'live' | 'favorite' | 'added' | 'recent';
|
||||
|
||||
export type DashboardHeroLiveCandidate =
|
||||
| { readonly origin: 'favorite'; readonly item: PortalFavoriteItem }
|
||||
| { readonly origin: 'recent'; readonly item: PortalRecentItem };
|
||||
|
||||
export type DashboardHeroSource =
|
||||
| { readonly kind: 'continue' | 'recent'; readonly item: PortalRecentItem }
|
||||
| ({ readonly kind: 'live' } & DashboardHeroLiveCandidate)
|
||||
| { readonly kind: 'favorite'; readonly item: PortalFavoriteItem }
|
||||
| { readonly kind: 'added'; readonly item: PortalAddedItem };
|
||||
|
||||
export interface DashboardHeroSourceInput {
|
||||
/** Unfinished movies/series, most recent first. */
|
||||
readonly continueItems: readonly PortalRecentItem[];
|
||||
/** The channel picked for the live slide, when one has a programme on air. */
|
||||
readonly live: DashboardHeroLiveCandidate | null;
|
||||
/**
|
||||
* Channels exist that could still fill the live slide (their EPG answer
|
||||
* may be pending). Its place is then kept free, so the slide arriving
|
||||
* late never pushes another one out from under the user.
|
||||
*/
|
||||
readonly reserveLive: boolean;
|
||||
/** Favourite movies/series. */
|
||||
readonly favorites: readonly PortalFavoriteItem[];
|
||||
readonly recentlyAdded: readonly PortalAddedItem[];
|
||||
/** The newest history row, used only when nothing else qualifies. */
|
||||
readonly mostRecent: PortalRecentItem | null;
|
||||
}
|
||||
|
||||
/** One title, whichever list it came from. */
|
||||
export function dashboardHeroItemKey(
|
||||
item: Pick<PortalActivityItem, 'playlist_id' | 'type' | 'xtream_id' | 'id'>
|
||||
): string {
|
||||
return `${item.playlist_id}::${item.type}::${item.xtream_id ?? item.id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Channels that may fill the live slide, in preference order: favourites
|
||||
* first (the slide is "your channel is on air"), then the channels watched
|
||||
* most recently. The live EPG presenter pins exactly these so their
|
||||
* programmes are looked up even when their rails are hidden or scrolled.
|
||||
*/
|
||||
export function selectDashboardHeroLiveCandidates(
|
||||
favoriteLive: readonly PortalFavoriteItem[],
|
||||
recentLive: readonly PortalRecentItem[]
|
||||
): DashboardHeroLiveCandidate[] {
|
||||
const seen = new Set<string>();
|
||||
const candidates: DashboardHeroLiveCandidate[] = [];
|
||||
const add = (candidate: DashboardHeroLiveCandidate) => {
|
||||
const key = dashboardHeroItemKey(candidate.item);
|
||||
if (candidate.item.type !== 'live' || seen.has(key)) {
|
||||
return;
|
||||
}
|
||||
seen.add(key);
|
||||
candidates.push(candidate);
|
||||
};
|
||||
|
||||
favoriteLive
|
||||
.slice(0, HERO_LIVE_FAVORITE_CANDIDATES)
|
||||
.forEach((item) => add({ origin: 'favorite', item }));
|
||||
recentLive
|
||||
.slice(0, HERO_LIVE_RECENT_CANDIDATES)
|
||||
.forEach((item) => add({ origin: 'recent', item }));
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/**
|
||||
* The rotation mix: the title to resume first, then a channel on air now,
|
||||
* then one favourite and one recent import; remaining places go to the next
|
||||
* unfinished title, favourite and import in turn. A title never appears
|
||||
* twice, and the order is stable so a slide arriving late (the live slide
|
||||
* waits for its EPG answer) slots in without reshuffling the rest. While
|
||||
* live candidates exist, one place stays reserved for the live slide, so
|
||||
* its arrival never evicts a slide the user may be viewing.
|
||||
*/
|
||||
export function pickDashboardHeroSources(
|
||||
input: DashboardHeroSourceInput
|
||||
): DashboardHeroSource[] {
|
||||
const seen = new Set<string>();
|
||||
const sources: DashboardHeroSource[] = [];
|
||||
const limit =
|
||||
input.reserveLive && !input.live
|
||||
? HERO_SLIDE_LIMIT - 1
|
||||
: HERO_SLIDE_LIMIT;
|
||||
const push = (source: DashboardHeroSource | null) => {
|
||||
if (!source || sources.length >= limit) {
|
||||
return;
|
||||
}
|
||||
const key = dashboardHeroItemKey(source.item);
|
||||
if (seen.has(key)) {
|
||||
return;
|
||||
}
|
||||
seen.add(key);
|
||||
sources.push(source);
|
||||
};
|
||||
const continueAt = (index: number) => {
|
||||
const item = input.continueItems[index];
|
||||
return item ? ({ kind: 'continue', item } as const) : null;
|
||||
};
|
||||
const favoriteAt = (index: number) => {
|
||||
const item = input.favorites[index];
|
||||
return item ? ({ kind: 'favorite', item } as const) : null;
|
||||
};
|
||||
const addedAt = (index: number) => {
|
||||
const item = input.recentlyAdded[index];
|
||||
return item ? ({ kind: 'added', item } as const) : null;
|
||||
};
|
||||
|
||||
push(continueAt(0));
|
||||
push(input.live ? { kind: 'live', ...input.live } : null);
|
||||
push(favoriteAt(0));
|
||||
push(addedAt(0));
|
||||
// Ends once every list has run out; duplicates only skip a place.
|
||||
for (let index = 1; sources.length < limit; index++) {
|
||||
const next = [continueAt(index), favoriteAt(index), addedAt(index)];
|
||||
if (next.every((source) => source === null)) {
|
||||
break;
|
||||
}
|
||||
next.forEach(push);
|
||||
}
|
||||
|
||||
if (sources.length === 0 && input.mostRecent) {
|
||||
push({ kind: 'recent', item: input.mostRecent });
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
@@ -11,7 +11,10 @@ describe('DashboardHeroTmdbService', () => {
|
||||
backdrop_path: '/serial.jpg',
|
||||
vote_average: 7.4,
|
||||
vote_count: 12,
|
||||
genres: [{ id: 1, name: 'Drama' }, { id: 2, name: 'Comedy' }],
|
||||
genres: [
|
||||
{ id: 1, name: 'Drama' },
|
||||
{ id: 2, name: 'Comedy' },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -53,6 +56,7 @@ describe('DashboardHeroTmdbService', () => {
|
||||
} as unknown as DashboardHeroTmdbItem;
|
||||
|
||||
let isEnabled: jest.Mock;
|
||||
let language: jest.Mock;
|
||||
let enrichMovie: jest.Mock;
|
||||
let enrichTv: jest.Mock;
|
||||
|
||||
@@ -61,7 +65,7 @@ describe('DashboardHeroTmdbService', () => {
|
||||
providers: [
|
||||
{
|
||||
provide: TmdbEnrichmentService,
|
||||
useValue: { isEnabled, enrichMovie, enrichTv },
|
||||
useValue: { isEnabled, language, enrichMovie, enrichTv },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -70,6 +74,7 @@ describe('DashboardHeroTmdbService', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
isEnabled = jest.fn().mockReturnValue(true);
|
||||
language = jest.fn().mockReturnValue('en-US');
|
||||
enrichMovie = jest.fn().mockResolvedValue(null);
|
||||
enrichTv = jest.fn().mockResolvedValue(tvDetails);
|
||||
});
|
||||
@@ -88,6 +93,57 @@ describe('DashboardHeroTmdbService', () => {
|
||||
expect(enrichMovie).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps the backdrop, rating, genres, overview and year for the slide', async () => {
|
||||
enrichTv.mockResolvedValue({
|
||||
...tvDetails,
|
||||
genres: [
|
||||
{ id: 1, name: 'Drama' },
|
||||
{ id: 2, name: 'Comedy' },
|
||||
{ id: 3, name: 'Crime' },
|
||||
],
|
||||
overview: ' A pharmacist enters big business. ',
|
||||
first_air_date: '2024-11-18',
|
||||
});
|
||||
const service = createService();
|
||||
|
||||
await expect(
|
||||
service.getExtras({ title: 'Big Pharma', type: 'series' })
|
||||
).resolves.toEqual({
|
||||
backdropUrl: expect.stringContaining('/serial.jpg'),
|
||||
rating: '7.4',
|
||||
genres: ['Drama', 'Comedy'],
|
||||
overview: 'A pharmacist enters big business.',
|
||||
year: 2024,
|
||||
});
|
||||
});
|
||||
|
||||
it('loads the extras again for another TMDB language', async () => {
|
||||
const service = createService();
|
||||
const item: DashboardHeroTmdbItem = { title: 'Serial', type: 'series' };
|
||||
|
||||
await service.getExtras(item);
|
||||
const englishKey = service.keyFor(item);
|
||||
await service.getExtras(item);
|
||||
expect(enrichTv).toHaveBeenCalledTimes(1);
|
||||
|
||||
language.mockReturnValue('ru-RU');
|
||||
expect(service.keyFor(item)).not.toBe(englishKey);
|
||||
await service.getExtras(item);
|
||||
expect(enrichTv).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('reports a missing overview and year as null', async () => {
|
||||
const service = createService();
|
||||
|
||||
const extras = await service.getExtras({
|
||||
title: 'No plot',
|
||||
type: 'series',
|
||||
});
|
||||
|
||||
expect(extras?.overview).toBeNull();
|
||||
expect(extras?.year).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to a TV lookup for movie-typed items without a movie match', async () => {
|
||||
const service = createService();
|
||||
|
||||
@@ -256,7 +312,9 @@ describe('DashboardHeroTmdbService', () => {
|
||||
},
|
||||
} as DashboardHeroTmdbItem;
|
||||
|
||||
expect(service.keyFor(stalkerItem)).not.toBe(service.keyFor(remake));
|
||||
expect(service.keyFor(stalkerItem)).not.toBe(
|
||||
service.keyFor(remake)
|
||||
);
|
||||
|
||||
await service.getExtras(stalkerItem);
|
||||
await service.getExtras(remake);
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { TmdbEnrichmentService, tmdbBackdropUrl } from '@iptvnator/services';
|
||||
import {
|
||||
TmdbEnrichmentService,
|
||||
extractYear,
|
||||
tmdbBackdropUrl,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
DashboardTmdbAttempt,
|
||||
DashboardTmdbLookupItem,
|
||||
@@ -12,6 +16,10 @@ export interface DashboardHeroTmdbExtras {
|
||||
readonly backdropUrl: string | null;
|
||||
readonly rating: string | null;
|
||||
readonly genres: readonly string[];
|
||||
/** TMDB plot, in the enrichment language; null when TMDB has none */
|
||||
readonly overview: string | null;
|
||||
/** Release (movie) or first-air (series) year */
|
||||
readonly year: number | null;
|
||||
}
|
||||
|
||||
/** Everything the hero lookup reads off an activity row */
|
||||
@@ -20,7 +28,8 @@ export type DashboardHeroTmdbItem = DashboardTmdbLookupItem;
|
||||
const MAX_HERO_GENRES = 2;
|
||||
|
||||
/**
|
||||
* Best-effort TMDB extras for the hero card (backdrop, rating, genres).
|
||||
* Best-effort TMDB extras for the hero slides (backdrop, rating, genres,
|
||||
* overview, year).
|
||||
* Goes through the enrichment facade, so items already opened in a detail
|
||||
* view resolve from the SQLite cache without network. Results are memoized
|
||||
* per lookup identity for the session — dashboard revisits skip the IPC
|
||||
@@ -45,10 +54,13 @@ export class DashboardHeroTmdbService {
|
||||
|
||||
/**
|
||||
* Identity of the lookup for an item — the memo key, and the staleness
|
||||
* guard callers compare against while a request is in flight.
|
||||
* guard callers compare against while a request is in flight. The TMDB
|
||||
* language is part of it: the overview and genre names are localized,
|
||||
* so a language change must load them again. Reactive when read inside
|
||||
* a computed or effect (settings signal underneath).
|
||||
*/
|
||||
keyFor(item: DashboardHeroTmdbItem): string {
|
||||
return dashboardTmdbLookupKey(item);
|
||||
return `${this.enrichment.language()}//${dashboardTmdbLookupKey(item)}`;
|
||||
}
|
||||
|
||||
getExtras(
|
||||
@@ -103,6 +115,9 @@ function toHeroExtras(details: {
|
||||
vote_average?: number;
|
||||
vote_count?: number;
|
||||
genres?: { name?: string }[];
|
||||
overview?: string;
|
||||
release_date?: string;
|
||||
first_air_date?: string;
|
||||
}): DashboardHeroTmdbExtras {
|
||||
const rating =
|
||||
(details.vote_count ?? 0) > 0 && details.vote_average
|
||||
@@ -116,5 +131,7 @@ function toHeroExtras(details: {
|
||||
.map((genre) => genre.name)
|
||||
.filter((name): name is string => Boolean(name))
|
||||
.slice(0, MAX_HERO_GENRES),
|
||||
overview: details.overview?.trim() || null,
|
||||
year: extractYear(details.release_date ?? details.first_air_date),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
@if (activeSlide(); as active) {
|
||||
<section
|
||||
class="hero"
|
||||
data-test-id="dashboard-hero"
|
||||
aria-roledescription="carousel"
|
||||
[attr.aria-label]="'WORKSPACE.DASHBOARD.HERO_LABEL' | translate"
|
||||
[class.hero--paused]="paused()"
|
||||
[class.hero--rotating]="canRotate()"
|
||||
(mouseenter)="setHovered(true)"
|
||||
(mouseleave)="setHovered(false)"
|
||||
(focusin)="onFocusIn()"
|
||||
(focusout)="onFocusOut($event)"
|
||||
>
|
||||
<!-- Every slide's backdrop stays mounted so switching crossfades
|
||||
and the next image is already decoded. -->
|
||||
<div class="hero__stage" aria-hidden="true">
|
||||
@for (slide of slides(); track slide.id) {
|
||||
<div
|
||||
class="hero__backdrop"
|
||||
[class.hero__backdrop--active]="$index === activeIndex()"
|
||||
[class.hero__backdrop--poster]="
|
||||
slide.backdropSource === 'poster'
|
||||
"
|
||||
[class.hero__backdrop--live]="slide.contentType === 'live'"
|
||||
[style.--hero-hue]="slide.accentHue"
|
||||
>
|
||||
<span
|
||||
class="hero__backdrop-fallback"
|
||||
[style.background]="
|
||||
slide.contentType === 'live'
|
||||
? null
|
||||
: slide.fallbackBackdropBackground
|
||||
"
|
||||
></span>
|
||||
@if (slide.backdropUrl; as backdropUrl) {
|
||||
<img
|
||||
class="hero__backdrop-image"
|
||||
[src]="backdropUrl"
|
||||
alt=""
|
||||
decoding="async"
|
||||
[attr.loading]="
|
||||
$index === activeIndex() ? 'eager' : 'lazy'
|
||||
"
|
||||
(error)="presenter.markImageFailed(backdropUrl)"
|
||||
/>
|
||||
<!-- No 16:9 backdrop: the sharp poster (or channel
|
||||
logo) becomes key art on the right. -->
|
||||
@if (slide.backdropSource === 'poster') {
|
||||
<img
|
||||
class="hero__backdrop-keyart"
|
||||
[src]="backdropUrl"
|
||||
alt=""
|
||||
decoding="async"
|
||||
/>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<span class="hero__scrim"></span>
|
||||
</div>
|
||||
|
||||
@for (slide of [active]; track slide.id) {
|
||||
<div
|
||||
class="hero__content"
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
data-test-id="dashboard-hero-slide"
|
||||
[attr.data-hero-kind]="slide.kind"
|
||||
[attr.aria-label]="
|
||||
'WORKSPACE.DASHBOARD.HERO_SLIDE_POSITION'
|
||||
| translate
|
||||
: {
|
||||
index: activeIndex() + 1,
|
||||
count: slides().length,
|
||||
}
|
||||
"
|
||||
[attr.aria-live]="canRotate() && !paused() ? 'off' : 'polite'"
|
||||
>
|
||||
<p class="hero__eyebrow">
|
||||
<span
|
||||
class="hero__type"
|
||||
[class.hero__type--live]="slide.contentType === 'live'"
|
||||
>{{ slide.typeLabelKey | translate }}</span
|
||||
>
|
||||
<span class="hero__eyebrow-sep" aria-hidden="true">·</span>
|
||||
<span>{{ slide.reasonLabelKey | translate }}</span>
|
||||
</p>
|
||||
|
||||
<h1 class="hero__title">{{ slide.title }}</h1>
|
||||
|
||||
<p class="hero__meta" data-test-id="dashboard-hero-badges">
|
||||
@if (slide.episodeBadge) {
|
||||
<span class="hero__pill">{{ slide.episodeBadge }}</span>
|
||||
}
|
||||
@if (slide.rating) {
|
||||
<span class="hero__pill hero__pill--rating"
|
||||
>★ {{ slide.rating }}</span
|
||||
>
|
||||
}
|
||||
@for (genre of slide.genres; track genre) {
|
||||
<span class="hero__pill">{{ genre }}</span>
|
||||
}
|
||||
@if (slide.category) {
|
||||
<span class="hero__pill">{{ slide.category }}</span>
|
||||
}
|
||||
@if (slide.timeRange) {
|
||||
<span class="hero__pill hero__pill--time">{{
|
||||
slide.timeRange
|
||||
}}</span>
|
||||
}
|
||||
@if (slide.year) {
|
||||
<span class="hero__pill">{{ slide.year }}</span>
|
||||
}
|
||||
@if (slide.source) {
|
||||
<span class="hero__pill hero__pill--source">{{
|
||||
slide.source
|
||||
}}</span>
|
||||
}
|
||||
</p>
|
||||
|
||||
@if (slide.programmeTitle) {
|
||||
<p
|
||||
class="hero__programme"
|
||||
data-test-id="dashboard-hero-live-epg"
|
||||
>
|
||||
{{ slide.programmeTitle }}
|
||||
</p>
|
||||
}
|
||||
@if (slide.description) {
|
||||
<p class="hero__description">{{ slide.description }}</p>
|
||||
}
|
||||
|
||||
@if (slide.progress !== null) {
|
||||
<span
|
||||
class="hero__progress"
|
||||
[class.hero__progress--live]="
|
||||
slide.contentType === 'live'
|
||||
"
|
||||
role="progressbar"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
[attr.aria-valuenow]="slide.progress"
|
||||
>
|
||||
<i [style.width.%]="slide.progress"></i>
|
||||
</span>
|
||||
}
|
||||
|
||||
<div class="hero__actions">
|
||||
@let primary = slide.primaryAction;
|
||||
<a
|
||||
class="hero__button hero__button--primary"
|
||||
[attr.data-test-id]="primary.testId"
|
||||
[routerLink]="primary.link"
|
||||
[state]="primary.state ?? {}"
|
||||
>
|
||||
<mat-icon aria-hidden="true">{{
|
||||
primary.icon
|
||||
}}</mat-icon>
|
||||
<span>{{ primary.labelKey | translate }}</span>
|
||||
@if (primary.remainingLabel; as remaining) {
|
||||
<span class="hero__button-detail"
|
||||
>·
|
||||
{{
|
||||
remaining.key | translate: remaining.params
|
||||
}}</span
|
||||
>
|
||||
}
|
||||
</a>
|
||||
@if (slide.secondaryAction; as secondary) {
|
||||
<a
|
||||
class="hero__button hero__button--secondary"
|
||||
[attr.data-test-id]="secondary.testId"
|
||||
[routerLink]="secondary.link"
|
||||
[state]="secondary.state ?? {}"
|
||||
>
|
||||
<mat-icon aria-hidden="true">{{
|
||||
secondary.icon
|
||||
}}</mat-icon>
|
||||
<span>{{ secondary.labelKey | translate }}</span>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (slides().length > 1) {
|
||||
<div class="hero__controls">
|
||||
@if (canRotate()) {
|
||||
<button
|
||||
type="button"
|
||||
class="hero__pause"
|
||||
data-test-id="dashboard-hero-pause"
|
||||
[attr.aria-pressed]="userPaused()"
|
||||
[attr.aria-label]="
|
||||
(userPaused()
|
||||
? 'WORKSPACE.DASHBOARD.HERO_PLAY'
|
||||
: 'WORKSPACE.DASHBOARD.HERO_PAUSE'
|
||||
) | translate
|
||||
"
|
||||
(click)="togglePaused()"
|
||||
>
|
||||
<mat-icon aria-hidden="true">{{
|
||||
userPaused() ? 'play_arrow' : 'pause'
|
||||
}}</mat-icon>
|
||||
</button>
|
||||
}
|
||||
<div
|
||||
class="hero__dots"
|
||||
role="group"
|
||||
[attr.aria-label]="
|
||||
'WORKSPACE.DASHBOARD.HERO_LABEL' | translate
|
||||
"
|
||||
>
|
||||
@for (slide of slides(); track slide.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="hero__dot"
|
||||
data-test-id="dashboard-hero-dot"
|
||||
[class.hero__dot--active]="$index === activeIndex()"
|
||||
[attr.aria-current]="
|
||||
$index === activeIndex() ? 'true' : null
|
||||
"
|
||||
[attr.aria-label]="
|
||||
'WORKSPACE.DASHBOARD.HERO_SHOW_SLIDE'
|
||||
| translate: { title: slide.title }
|
||||
"
|
||||
[attr.tabindex]="$index === activeIndex() ? 0 : -1"
|
||||
(click)="show($index)"
|
||||
(keydown)="onDotKeydown($event, $index)"
|
||||
>
|
||||
<span
|
||||
class="hero__dot-fill"
|
||||
(animationend)="onRotationTick()"
|
||||
></span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
} @else if (presenter.loading()) {
|
||||
<section
|
||||
class="hero hero--skeleton"
|
||||
aria-hidden="true"
|
||||
aria-busy="true"
|
||||
data-test-id="dashboard-hero-skeleton"
|
||||
>
|
||||
<div class="hero__content">
|
||||
<span class="hero__skeleton hero__skeleton--eyebrow"></span>
|
||||
<span class="hero__skeleton hero__skeleton--title"></span>
|
||||
<span class="hero__skeleton hero__skeleton--meta"></span>
|
||||
<span class="hero__skeleton hero__skeleton--button"></span>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
@@ -0,0 +1,670 @@
|
||||
// Cinematic dashboard hero. Bleeds to the edges of the workspace content
|
||||
// area (the page's `--dashboard-gutter` padding and the centred max-width
|
||||
// are both cancelled out), keeps a height ceiling so the first rail still
|
||||
// starts above the fold, and dissolves into the page background instead of
|
||||
// sitting in a raised card.
|
||||
//
|
||||
// The scrim is the page background itself (`--app-content-bg`), so the
|
||||
// light theme gets a light scrim with dark text and the dark theme the
|
||||
// reverse, with the same geometry.
|
||||
|
||||
// The host renders nothing when there is no slide and no skeleton; with
|
||||
// `display: contents` it then adds no flex gap to the rails page either.
|
||||
:host {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.hero {
|
||||
// Offset of the rails' content edge from the content-area edge: the page
|
||||
// gutter plus whatever the centred max-width leaves on either side.
|
||||
// `100cqw` is the rails page host (the `dashboard` size container).
|
||||
--hero-inset: calc(
|
||||
var(--dashboard-gutter, 28px) +
|
||||
max(0px, (100cqw - var(--dashboard-max-width, 1440px)) / 2)
|
||||
);
|
||||
--hero-scrim: var(--app-content-bg, #161a22);
|
||||
--hero-heading: var(--app-heading-color, #d8dce8);
|
||||
--hero-body: color-mix(
|
||||
in srgb,
|
||||
var(--app-heading-color, #d8dce8) 72%,
|
||||
var(--hero-scrim)
|
||||
);
|
||||
--hero-pill-bg: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface, #e6e1e5) 12%,
|
||||
transparent
|
||||
);
|
||||
// Gold in the dark theme, a deeper amber on the light one: mixed with
|
||||
// the theme's heading colour rather than detected per theme.
|
||||
--hero-rating: color-mix(
|
||||
in srgb,
|
||||
#f5b400 60%,
|
||||
var(--app-heading-color, #d8dce8)
|
||||
);
|
||||
|
||||
position: relative;
|
||||
// A floor, not a fixed height: the slide sits in normal flow at the
|
||||
// bottom, so an unusually full slide (two-line title, programme,
|
||||
// synopsis, wrapped pills) grows the banner instead of losing its top.
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
box-sizing: border-box;
|
||||
min-height: clamp(320px, 42vh, 520px);
|
||||
// Flush with the content area's top and sides; the rails page's own
|
||||
// top padding and gutters are cancelled. The negative bottom margin
|
||||
// tucks the first rail into the fade (the gradient does the spacing).
|
||||
margin: calc(-1 * var(--dashboard-top-gutter, 24px))
|
||||
calc(-1 * var(--hero-inset)) -12px;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
color: var(--hero-heading);
|
||||
}
|
||||
|
||||
// ── Backdrops ──────────────────────────────────────────────────────────────
|
||||
.hero__stage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hero__backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 700ms ease;
|
||||
|
||||
&--active {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.hero__backdrop-fallback,
|
||||
.hero__backdrop-image {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.hero__backdrop-image {
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
object-position: center 22%;
|
||||
transform: scale(1.04);
|
||||
transition: transform 8s ease-out;
|
||||
|
||||
.hero__backdrop--active & {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
// No 16:9 backdrop (Xtream/Stalker often lack one): the poster, blurred and
|
||||
// scaled, washes its colours across the whole stage…
|
||||
.hero__backdrop--poster .hero__backdrop-image {
|
||||
filter: blur(48px) saturate(1.4);
|
||||
transform: scale(1.35);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.hero__backdrop--poster.hero__backdrop--active .hero__backdrop-image {
|
||||
transform: scale(1.3);
|
||||
}
|
||||
|
||||
// …and the sharp poster fills the right-hand side as key art, faded into
|
||||
// that wash, so the slide still shows the actual title artwork.
|
||||
.hero__backdrop-keyart {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
width: min(46%, 640px);
|
||||
object-fit: cover;
|
||||
object-position: center 18%;
|
||||
-webkit-mask-image: linear-gradient(90deg, transparent 0%, #000 45%);
|
||||
mask-image: linear-gradient(90deg, transparent 0%, #000 45%);
|
||||
}
|
||||
|
||||
// Live channels: a logo is not a backdrop. The stage is a title-tinted
|
||||
// glow with a hint of the live accent; the logo, when there is one, sits
|
||||
// on the right as the slide's artwork over a soft wash of its own colours.
|
||||
.hero__backdrop--live {
|
||||
.hero__backdrop-fallback {
|
||||
background:
|
||||
radial-gradient(
|
||||
55% 120% at 78% 30%,
|
||||
hsl(var(--hero-hue, 220) 60% 45% / 0.45),
|
||||
transparent 70%
|
||||
),
|
||||
radial-gradient(
|
||||
40% 90% at 100% 100%,
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--app-live-color, #ff8f8f) 28%,
|
||||
transparent
|
||||
),
|
||||
transparent 70%
|
||||
),
|
||||
var(--hero-scrim);
|
||||
}
|
||||
|
||||
&.hero__backdrop--active .hero__backdrop-image,
|
||||
.hero__backdrop-image {
|
||||
inset: 10% 6% 10% auto;
|
||||
width: 40%;
|
||||
height: 80%;
|
||||
object-fit: contain;
|
||||
filter: blur(56px) saturate(1.6);
|
||||
opacity: 0.5;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.hero__backdrop-keyart {
|
||||
top: 50%;
|
||||
right: calc(var(--hero-inset) + 8%);
|
||||
width: min(22%, 240px);
|
||||
height: 50%;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
transform: translateY(-50%);
|
||||
filter: drop-shadow(0 12px 32px rgba(0, 0, 0, 0.45));
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
}
|
||||
}
|
||||
|
||||
.hero__scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
color-mix(in srgb, var(--hero-scrim) 96%, transparent) 0%,
|
||||
color-mix(in srgb, var(--hero-scrim) 85%, transparent) 30%,
|
||||
color-mix(in srgb, var(--hero-scrim) 35%, transparent) 60%,
|
||||
transparent 100%
|
||||
),
|
||||
linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--hero-scrim) 30%, transparent) 0%,
|
||||
transparent 22%,
|
||||
transparent 55%,
|
||||
var(--hero-scrim) 100%
|
||||
);
|
||||
}
|
||||
|
||||
// ── Slide content ──────────────────────────────────────────────────────────
|
||||
.hero__content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
box-sizing: border-box;
|
||||
// Top padding keeps clear of the title bar edge when the slide grows.
|
||||
margin-left: var(--hero-inset);
|
||||
padding: 72px 0 34px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
max-width: min(560px, 55%);
|
||||
animation: hero-content-in 420ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
|
||||
}
|
||||
|
||||
@keyframes hero-content-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.hero__eyebrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
letter-spacing: 1.4px;
|
||||
text-transform: uppercase;
|
||||
color: var(--hero-body);
|
||||
}
|
||||
|
||||
.hero__type--live {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--app-live-color, #ff8f8f);
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
box-shadow: 0 0 8px currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
.hero__eyebrow-sep {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.hero__title {
|
||||
margin: 0;
|
||||
font-size: clamp(28px, 3.4vw, 44px);
|
||||
font-weight: 700;
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.5px;
|
||||
color: var(--hero-heading);
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.hero__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.hero__pill {
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--hero-pill-bg);
|
||||
color: var(--hero-heading);
|
||||
white-space: nowrap;
|
||||
|
||||
&--rating {
|
||||
color: var(--hero-rating);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&--time {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
&--source {
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--hero-body);
|
||||
}
|
||||
}
|
||||
|
||||
.hero__programme {
|
||||
margin: 0;
|
||||
max-width: 100%;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
color: var(--hero-heading);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hero__description {
|
||||
margin: 0;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.5;
|
||||
color: var(--hero-body);
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.hero__progress {
|
||||
display: block;
|
||||
width: 220px;
|
||||
max-width: 100%;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface, #e6e1e5) 18%,
|
||||
transparent
|
||||
);
|
||||
|
||||
i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--app-selection-color, #78adff);
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
&--live i {
|
||||
background: var(--app-live-color, #ff8f8f);
|
||||
}
|
||||
}
|
||||
|
||||
.hero__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.hero__button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 38px;
|
||||
padding: 0 18px 0 14px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
box-shadow 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
|
||||
mat-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 20px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--app-selection-color, #78adff);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
&--primary {
|
||||
background: var(--app-selection-color, #78adff);
|
||||
color: var(--app-selection-on-color, #071225);
|
||||
box-shadow: 0 6px 18px -8px
|
||||
var(--app-selection-glow, rgba(120, 173, 255, 0.6));
|
||||
|
||||
&:hover {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-selection-color, #78adff) 88%,
|
||||
#fff
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
&--secondary {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface, #e6e1e5) 12%,
|
||||
transparent
|
||||
);
|
||||
color: var(--hero-heading);
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--app-on-surface, #e6e1e5) 14%, transparent);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
backdrop-filter: blur(8px);
|
||||
|
||||
&:hover {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface, #e6e1e5) 20%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hero__button-detail {
|
||||
font-weight: 500;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
// ── Rotation controls ──────────────────────────────────────────────────────
|
||||
.hero__controls {
|
||||
position: absolute;
|
||||
right: var(--hero-inset);
|
||||
bottom: 34px;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 999px;
|
||||
// Keeps the dots legible over bright key art in both themes.
|
||||
background: color-mix(in srgb, var(--hero-scrim) 55%, transparent);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.hero__pause {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--hero-body);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 0.15s ease,
|
||||
background-color 0.15s ease;
|
||||
|
||||
mat-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
font-size: 18px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--hero-pill-bg);
|
||||
color: var(--hero-heading);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--app-selection-color, #78adff);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero__dots {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
// 6px dots inside a 20px hit area; the active one stretches into a pill
|
||||
// whose fill runs for one rotation interval.
|
||||
.hero__dot {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
grid-area: 1 / 1;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface, #e6e1e5) 32%,
|
||||
transparent
|
||||
);
|
||||
transition:
|
||||
width 0.25s ease,
|
||||
background-color 0.15s ease;
|
||||
}
|
||||
|
||||
&:hover::before {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface, #e6e1e5) 55%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--app-selection-color, #78adff);
|
||||
outline-offset: -2px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
&--active {
|
||||
width: 30px;
|
||||
|
||||
&::before {
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hero__dot-fill {
|
||||
grid-area: 1 / 1;
|
||||
justify-self: start;
|
||||
// Aligns with the 18px pill centred in the 30px button.
|
||||
margin-left: 6px;
|
||||
width: 0;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--hero-heading);
|
||||
pointer-events: none;
|
||||
|
||||
.hero__dot--active & {
|
||||
width: 18px;
|
||||
}
|
||||
|
||||
.hero--rotating .hero__dot--active & {
|
||||
animation: hero-dot-fill var(--hero-rotation-ms, 8000ms) linear both;
|
||||
}
|
||||
|
||||
.hero--paused .hero__dot--active & {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes hero-dot-fill {
|
||||
from {
|
||||
width: 0;
|
||||
}
|
||||
to {
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Skeleton ───────────────────────────────────────────────────────────────
|
||||
.hero--skeleton {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
var(--app-widget-bg, #1e2330) 0%,
|
||||
var(--hero-scrim) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.hero__skeleton {
|
||||
display: block;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
color-mix(in srgb, var(--app-on-surface, #e6e1e5) 8%, transparent) 0%,
|
||||
color-mix(in srgb, var(--app-on-surface, #e6e1e5) 16%, transparent) 50%,
|
||||
color-mix(in srgb, var(--app-on-surface, #e6e1e5) 8%, transparent) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: hero-skeleton-shimmer 1.6s linear infinite;
|
||||
|
||||
&--eyebrow {
|
||||
width: 180px;
|
||||
height: 11px;
|
||||
}
|
||||
|
||||
&--title {
|
||||
width: 380px;
|
||||
max-width: 100%;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
&--meta {
|
||||
width: 260px;
|
||||
height: 20px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
&--button {
|
||||
width: 190px;
|
||||
height: 38px;
|
||||
border-radius: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes hero-skeleton-shimmer {
|
||||
from {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
to {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Narrow content area ────────────────────────────────────────────────────
|
||||
@container dashboard (max-width: 720px) {
|
||||
.hero__content {
|
||||
margin-right: var(--hero-inset);
|
||||
// Clears the rotation controls, which move to the top here.
|
||||
padding: 64px 0 28px;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.hero__controls {
|
||||
top: 16px;
|
||||
bottom: auto;
|
||||
}
|
||||
|
||||
.hero__scrim {
|
||||
background:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
color-mix(in srgb, var(--hero-scrim) 80%, transparent) 0%,
|
||||
color-mix(in srgb, var(--hero-scrim) 40%, transparent) 100%
|
||||
),
|
||||
linear-gradient(180deg, transparent 30%, var(--hero-scrim) 100%);
|
||||
}
|
||||
|
||||
.hero__backdrop-keyart {
|
||||
width: 70%;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hero__backdrop,
|
||||
.hero__backdrop-image {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.hero__content,
|
||||
.hero__skeleton {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed, type ComponentFixture } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { DashboardHeroComponent } from './dashboard-hero.component';
|
||||
import { DashboardHeroSlidesPresenter } from './dashboard-hero-slides.presenter';
|
||||
import type { DashboardHeroSlide } from './dashboard-hero.utils';
|
||||
|
||||
const slide = (id: string, title: string): DashboardHeroSlide => ({
|
||||
id,
|
||||
kind: 'continue',
|
||||
contentType: 'movie',
|
||||
title,
|
||||
typeLabelKey: 'WORKSPACE.DASHBOARD.TYPE_MOVIE',
|
||||
reasonLabelKey: 'WORKSPACE.DASHBOARD.CONTINUE_WATCHING',
|
||||
episodeBadge: null,
|
||||
rating: null,
|
||||
genres: [],
|
||||
year: null,
|
||||
source: 'Source',
|
||||
programmeTitle: null,
|
||||
category: null,
|
||||
timeRange: null,
|
||||
description: null,
|
||||
progress: null,
|
||||
accentHue: 200,
|
||||
backdropSource: 'fallback',
|
||||
fallbackBackdropBackground: 'none',
|
||||
fallbackPosterBackground: 'none',
|
||||
hasBackdrop: false,
|
||||
primaryAction: {
|
||||
labelKey: 'WORKSPACE.DASHBOARD.HERO_CONTINUE',
|
||||
icon: 'play_arrow',
|
||||
link: ['/workspace', id],
|
||||
testId: 'dashboard-hero-primary-action',
|
||||
},
|
||||
secondaryAction: null,
|
||||
});
|
||||
|
||||
describe('DashboardHeroComponent', () => {
|
||||
let fixture: ComponentFixture<DashboardHeroComponent>;
|
||||
let slides: ReturnType<typeof signal<DashboardHeroSlide[]>>;
|
||||
let reducedMotion: boolean;
|
||||
|
||||
const host = () => fixture.nativeElement as HTMLElement;
|
||||
const activeTitle = () =>
|
||||
host().querySelector('[data-test-id=dashboard-hero-slide] h1')
|
||||
?.textContent;
|
||||
const dots = () =>
|
||||
Array.from(
|
||||
host().querySelectorAll<HTMLButtonElement>(
|
||||
'[data-test-id=dashboard-hero-dot]'
|
||||
)
|
||||
);
|
||||
const finishActiveDot = () => {
|
||||
const fill = host().querySelector(
|
||||
'.hero__dot--active .hero__dot-fill'
|
||||
) as HTMLElement;
|
||||
fill.dispatchEvent(new Event('animationend'));
|
||||
fixture.detectChanges();
|
||||
};
|
||||
|
||||
function render(): void {
|
||||
window.matchMedia = jest.fn().mockReturnValue({
|
||||
matches: reducedMotion,
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
});
|
||||
TestBed.configureTestingModule({
|
||||
imports: [DashboardHeroComponent, TranslateModule.forRoot()],
|
||||
providers: [provideRouter([])],
|
||||
});
|
||||
TestBed.overrideComponent(DashboardHeroComponent, {
|
||||
set: {
|
||||
providers: [
|
||||
{
|
||||
provide: DashboardHeroSlidesPresenter,
|
||||
useValue: {
|
||||
slides,
|
||||
loading: signal(false),
|
||||
markImageFailed: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
fixture = TestBed.createComponent(DashboardHeroComponent);
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
reducedMotion = false;
|
||||
slides = signal([
|
||||
slide('a', 'First'),
|
||||
slide('b', 'Second'),
|
||||
slide('c', 'Third'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('advances when the active dot finishes its rotation interval', () => {
|
||||
render();
|
||||
expect(activeTitle()).toBe('First');
|
||||
|
||||
finishActiveDot();
|
||||
expect(activeTitle()).toBe('Second');
|
||||
|
||||
finishActiveDot();
|
||||
finishActiveDot();
|
||||
expect(activeTitle()).toBe('First');
|
||||
});
|
||||
|
||||
it('does not advance while the pointer or focus is inside the hero', () => {
|
||||
render();
|
||||
const section = host().querySelector(
|
||||
'[data-test-id=dashboard-hero]'
|
||||
) as HTMLElement;
|
||||
|
||||
section.dispatchEvent(new Event('mouseenter'));
|
||||
fixture.detectChanges();
|
||||
expect(section.classList).toContain('hero--paused');
|
||||
finishActiveDot();
|
||||
expect(activeTitle()).toBe('First');
|
||||
|
||||
section.dispatchEvent(new Event('mouseleave'));
|
||||
fixture.detectChanges();
|
||||
finishActiveDot();
|
||||
expect(activeTitle()).toBe('Second');
|
||||
});
|
||||
|
||||
it('lets the user pause and resume the rotation', () => {
|
||||
render();
|
||||
const pause = host().querySelector(
|
||||
'[data-test-id=dashboard-hero-pause]'
|
||||
) as HTMLButtonElement;
|
||||
|
||||
pause.click();
|
||||
fixture.detectChanges();
|
||||
expect(pause.getAttribute('aria-pressed')).toBe('true');
|
||||
finishActiveDot();
|
||||
expect(activeTitle()).toBe('First');
|
||||
|
||||
pause.click();
|
||||
fixture.detectChanges();
|
||||
finishActiveDot();
|
||||
expect(activeTitle()).toBe('Second');
|
||||
});
|
||||
|
||||
it('resumes on Play even while the button keeps the pointer and focus', () => {
|
||||
render();
|
||||
const section = host().querySelector(
|
||||
'[data-test-id=dashboard-hero]'
|
||||
) as HTMLElement;
|
||||
const pause = host().querySelector(
|
||||
'[data-test-id=dashboard-hero-pause]'
|
||||
) as HTMLButtonElement;
|
||||
section.dispatchEvent(new Event('mouseenter'));
|
||||
section.dispatchEvent(new FocusEvent('focusin'));
|
||||
|
||||
pause.click();
|
||||
fixture.detectChanges();
|
||||
pause.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(section.classList).not.toContain('hero--paused');
|
||||
finishActiveDot();
|
||||
expect(activeTitle()).toBe('Second');
|
||||
});
|
||||
|
||||
it('switches slides from the dots, with roving arrow keys', () => {
|
||||
render();
|
||||
|
||||
dots()[2].click();
|
||||
fixture.detectChanges();
|
||||
expect(activeTitle()).toBe('Third');
|
||||
expect(dots()[2].getAttribute('aria-current')).toBe('true');
|
||||
expect(dots()[2].tabIndex).toBe(0);
|
||||
expect(dots()[0].tabIndex).toBe(-1);
|
||||
|
||||
dots()[2].dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowRight' })
|
||||
);
|
||||
fixture.detectChanges();
|
||||
expect(activeTitle()).toBe('First');
|
||||
});
|
||||
|
||||
it('keeps the current slide when a slide arrives late', () => {
|
||||
render();
|
||||
dots()[1].click();
|
||||
fixture.detectChanges();
|
||||
|
||||
slides.set([
|
||||
slide('a', 'First'),
|
||||
slide('live', 'Live channel'),
|
||||
slide('b', 'Second'),
|
||||
slide('c', 'Third'),
|
||||
]);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(activeTitle()).toBe('Second');
|
||||
expect(dots()).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('keeps the first slide when a slide arrives ahead of it untouched', () => {
|
||||
slides.set([slide('fav', 'Favourite'), slide('added', 'Import')]);
|
||||
render();
|
||||
expect(activeTitle()).toBe('Favourite');
|
||||
|
||||
slides.set([
|
||||
slide('live', 'Live channel'),
|
||||
slide('fav', 'Favourite'),
|
||||
slide('added', 'Import'),
|
||||
]);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(activeTitle()).toBe('Favourite');
|
||||
});
|
||||
|
||||
it('shows the slide now at the same position when the active one goes', () => {
|
||||
render();
|
||||
dots()[1].click();
|
||||
fixture.detectChanges();
|
||||
|
||||
slides.set([slide('a', 'First'), slide('c', 'Third')]);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(activeTitle()).toBe('Third');
|
||||
});
|
||||
|
||||
it('never auto-rotates under reduced motion, but the dots still work', () => {
|
||||
reducedMotion = true;
|
||||
render();
|
||||
|
||||
expect(
|
||||
host().querySelector('[data-test-id=dashboard-hero-pause]')
|
||||
).toBeNull();
|
||||
finishActiveDot();
|
||||
expect(activeTitle()).toBe('First');
|
||||
|
||||
dots()[1].click();
|
||||
fixture.detectChanges();
|
||||
expect(activeTitle()).toBe('Second');
|
||||
});
|
||||
|
||||
it('renders a single slide without rotation controls', () => {
|
||||
slides.set([slide('a', 'Only')]);
|
||||
render();
|
||||
|
||||
expect(activeTitle()).toBe('Only');
|
||||
expect(dots()).toHaveLength(0);
|
||||
expect(
|
||||
host()
|
||||
.querySelector('[data-test-id=dashboard-hero-primary-action]')
|
||||
?.getAttribute('href')
|
||||
).toBe('/workspace/a');
|
||||
});
|
||||
|
||||
it('renders nothing when there is nothing to feature', () => {
|
||||
slides.set([]);
|
||||
render();
|
||||
|
||||
expect(
|
||||
host().querySelector('[data-test-id=dashboard-hero]')
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
DestroyRef,
|
||||
inject,
|
||||
linkedSignal,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { DashboardHeroSlidesPresenter } from './dashboard-hero-slides.presenter';
|
||||
import { HERO_ROTATION_MS } from './dashboard-hero-slides.utils';
|
||||
import type { DashboardHeroSlide } from './dashboard-hero.utils';
|
||||
|
||||
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
|
||||
|
||||
/**
|
||||
* Cinematic dashboard hero: a full-bleed backdrop that rotates between an
|
||||
* unfinished title, a favourite channel on air now and a few discovery
|
||||
* picks (see `pickDashboardHeroSources`).
|
||||
*
|
||||
* Rotation is driven by the active dot's CSS fill animation: its
|
||||
* `animationend` advances the slide, so pausing (hover, focus inside the
|
||||
* hero, the pause button) is just `animation-play-state: paused` and resumes
|
||||
* where it stopped. With `prefers-reduced-motion` there is no animation and
|
||||
* therefore no auto-advance; the dots still switch slides.
|
||||
*
|
||||
* The active slide is tracked by id, so a slide that arrives late (the live
|
||||
* slide waits for its EPG answer) never yanks the user off the current one.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'lib-dashboard-hero',
|
||||
imports: [MatIcon, RouterLink, TranslatePipe],
|
||||
templateUrl: './dashboard-hero.component.html',
|
||||
styleUrl: './dashboard-hero.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
providers: [DashboardHeroSlidesPresenter],
|
||||
host: {
|
||||
'[style.--hero-rotation-ms]': 'rotationMs + "ms"',
|
||||
},
|
||||
})
|
||||
export class DashboardHeroComponent {
|
||||
readonly presenter = inject(DashboardHeroSlidesPresenter);
|
||||
readonly slides = this.presenter.slides;
|
||||
readonly rotationMs = HERO_ROTATION_MS;
|
||||
|
||||
/**
|
||||
* The slide on screen, by id. Pinned to the first slide as soon as one
|
||||
* exists, so a late slide inserted ahead of it cannot take its place;
|
||||
* if the active slide itself disappears, the one now at its position
|
||||
* takes over.
|
||||
*/
|
||||
private readonly activeId = linkedSignal<
|
||||
DashboardHeroSlide[],
|
||||
string | null
|
||||
>({
|
||||
source: this.slides,
|
||||
computation: (slides, previous) => {
|
||||
const currentId = previous?.value ?? null;
|
||||
if (currentId && slides.some((slide) => slide.id === currentId)) {
|
||||
return currentId;
|
||||
}
|
||||
const previousIndex =
|
||||
previous?.source.findIndex((slide) => slide.id === currentId) ??
|
||||
-1;
|
||||
const index = Math.min(
|
||||
Math.max(previousIndex, 0),
|
||||
slides.length - 1
|
||||
);
|
||||
return slides[index]?.id ?? null;
|
||||
},
|
||||
});
|
||||
private readonly hovered = signal(false);
|
||||
private readonly focusWithin = signal(false);
|
||||
readonly userPaused = signal(false);
|
||||
readonly reducedMotion = signal(false);
|
||||
|
||||
readonly activeIndex = computed(() => {
|
||||
const index = this.slides().findIndex(
|
||||
(slide) => slide.id === this.activeId()
|
||||
);
|
||||
return index >= 0 ? index : 0;
|
||||
});
|
||||
|
||||
readonly activeSlide = computed(
|
||||
() => this.slides()[this.activeIndex()] ?? null
|
||||
);
|
||||
|
||||
readonly canRotate = computed(
|
||||
() => this.slides().length > 1 && !this.reducedMotion()
|
||||
);
|
||||
|
||||
readonly paused = computed(
|
||||
() => this.userPaused() || this.hovered() || this.focusWithin()
|
||||
);
|
||||
|
||||
constructor() {
|
||||
const media =
|
||||
typeof window !== 'undefined' && window.matchMedia
|
||||
? window.matchMedia(REDUCED_MOTION_QUERY)
|
||||
: null;
|
||||
if (media) {
|
||||
this.reducedMotion.set(media.matches);
|
||||
const onChange = (event: MediaQueryListEvent) =>
|
||||
this.reducedMotion.set(event.matches);
|
||||
media.addEventListener('change', onChange);
|
||||
inject(DestroyRef).onDestroy(() =>
|
||||
media.removeEventListener('change', onChange)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
show(index: number): void {
|
||||
const slide = this.slides()[index];
|
||||
if (slide) {
|
||||
this.activeId.set(slide.id);
|
||||
}
|
||||
}
|
||||
|
||||
/** The active dot's fill animation finished: time for the next slide. */
|
||||
onRotationTick(): void {
|
||||
if (!this.canRotate() || this.paused()) {
|
||||
return;
|
||||
}
|
||||
this.show((this.activeIndex() + 1) % this.slides().length);
|
||||
}
|
||||
|
||||
togglePaused(): void {
|
||||
if (!this.userPaused()) {
|
||||
this.userPaused.set(true);
|
||||
return;
|
||||
}
|
||||
// An explicit Play wins over the implicit pauses: the pointer and
|
||||
// the focus are on this very button, so they would otherwise keep
|
||||
// the rotation stopped. They re-arm on the next enter / focus move.
|
||||
this.userPaused.set(false);
|
||||
this.hovered.set(false);
|
||||
this.focusWithin.set(false);
|
||||
}
|
||||
|
||||
setHovered(hovered: boolean): void {
|
||||
this.hovered.set(hovered);
|
||||
}
|
||||
|
||||
onFocusIn(): void {
|
||||
this.focusWithin.set(true);
|
||||
}
|
||||
|
||||
onFocusOut(event: FocusEvent): void {
|
||||
const host = event.currentTarget as HTMLElement | null;
|
||||
const next = event.relatedTarget as Node | null;
|
||||
if (!host || !next || !host.contains(next)) {
|
||||
this.focusWithin.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
/** Roving arrows between the dots, like a tab list. */
|
||||
onDotKeydown(event: KeyboardEvent, index: number): void {
|
||||
const count = this.slides().length;
|
||||
const step =
|
||||
event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0;
|
||||
if (step === 0 || count < 2) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const next = (index + step + count) % count;
|
||||
this.show(next);
|
||||
const group = (event.currentTarget as HTMLElement).parentElement;
|
||||
group?.querySelectorAll<HTMLButtonElement>('button')[next]?.focus();
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,49 @@
|
||||
import type { CollectionContentType } from '@iptvnator/portal/shared/util';
|
||||
import type { DashboardRemainingLabel } from './dashboard-playback.utils';
|
||||
import type { DashboardHeroSlideKind } from './dashboard-hero-slides.utils';
|
||||
|
||||
export interface DashboardHeroModel {
|
||||
readonly backdropUrl?: string;
|
||||
readonly backdropSource: DashboardHeroBackdropSource;
|
||||
readonly contentType?: CollectionContentType;
|
||||
readonly fallbackBackdropBackground: string;
|
||||
readonly fallbackPosterBackground: string;
|
||||
readonly hasBackdrop: boolean;
|
||||
/** A hero call to action: a router link, never an in-place side effect. */
|
||||
export interface DashboardHeroAction {
|
||||
readonly labelKey: string;
|
||||
readonly icon: string;
|
||||
readonly link: string[];
|
||||
readonly posterUrl?: string;
|
||||
readonly state?: Record<string, unknown>;
|
||||
readonly subtitle: string;
|
||||
readonly title: string;
|
||||
/** TMDB vote average ("8.1"), patched in async when available */
|
||||
readonly rating?: string | null;
|
||||
/** Up to two TMDB genre names, patched in async when available */
|
||||
readonly genres?: readonly string[];
|
||||
/** "S2·E5" for series with a tracked episode position */
|
||||
readonly episodeBadge?: string | null;
|
||||
/** 0-100 watched, when a resume position is known. */
|
||||
readonly watchProgress?: number | null;
|
||||
/** Appended as "· 22m left" on a resume action. */
|
||||
readonly remainingLabel?: DashboardRemainingLabel | null;
|
||||
readonly nowPlayingTitle?: string | null;
|
||||
readonly nowPlayingTimeRange?: string | null;
|
||||
readonly nowPlayingProgress?: number | null;
|
||||
readonly testId: string;
|
||||
}
|
||||
|
||||
/** One rotation slide of the cinematic dashboard hero. */
|
||||
export interface DashboardHeroSlide extends DashboardHeroArtwork {
|
||||
/** Stable across data refreshes: the active slide is tracked by it. */
|
||||
readonly id: string;
|
||||
readonly kind: DashboardHeroSlideKind;
|
||||
readonly contentType: CollectionContentType;
|
||||
readonly title: string;
|
||||
/** "Movie" / "Series" / "Live" */
|
||||
readonly typeLabelKey: string;
|
||||
/** Why the slide is here: "Continue watching", "Favourite channel"… */
|
||||
readonly reasonLabelKey: string;
|
||||
readonly episodeBadge: string | null;
|
||||
/** TMDB vote average ("8.1"), patched in async when available */
|
||||
readonly rating: string | null;
|
||||
/** Up to two TMDB genre names, patched in async when available */
|
||||
readonly genres: readonly string[];
|
||||
readonly year: number | null;
|
||||
/** Source name (playlist label), never a raw URL or credentials */
|
||||
readonly source: string;
|
||||
/** Live: programme on air now, its guide category and "HH:mm – HH:mm" */
|
||||
readonly programmeTitle: string | null;
|
||||
readonly category: string | null;
|
||||
readonly timeRange: string | null;
|
||||
/** TMDB overview for titles, guide synopsis for live programmes */
|
||||
readonly description: string | null;
|
||||
/** 0-100: watched share, or elapsed share of the live programme */
|
||||
readonly progress: number | null;
|
||||
/** Title-derived hue for the live slide's glow (0-359) */
|
||||
readonly accentHue: number;
|
||||
readonly primaryAction: DashboardHeroAction;
|
||||
readonly secondaryAction: DashboardHeroAction | null;
|
||||
}
|
||||
|
||||
export type DashboardHeroBackdropSource = 'backdrop' | 'poster' | 'fallback';
|
||||
@@ -101,6 +119,11 @@ function buildFallbackBackground(
|
||||
return `linear-gradient(135deg, hsl(${hue}, ${saturationA}%, ${lightnessA}%) 0%, hsl(${h2}, ${saturationB}%, ${lightnessB}%) 100%)`;
|
||||
}
|
||||
|
||||
/** Stable hue for a title, shared by every generated fallback surface. */
|
||||
export function dashboardHeroHue(text: string): number {
|
||||
return calculateHue(text || 'placeholder');
|
||||
}
|
||||
|
||||
function calculateHue(text: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
|
||||
+67
-18
@@ -43,6 +43,8 @@ describe('DashboardLiveEpgPresenter', () => {
|
||||
let getCurrentProgramsForChannels: jest.Mock;
|
||||
let playlists: ReturnType<typeof signal<PlaylistMeta[]>>;
|
||||
let recentItems: ReturnType<typeof signal<PortalActivityItem[]>>;
|
||||
let favoriteLiveItems: ReturnType<typeof signal<PortalActivityItem[]>>;
|
||||
let recentLiveItems: ReturnType<typeof signal<PortalActivityItem[]>>;
|
||||
let dashboardRails: ReturnType<
|
||||
typeof signal<typeof DEFAULT_DASHBOARD_RAILS_SETTINGS>
|
||||
>;
|
||||
@@ -74,6 +76,8 @@ describe('DashboardLiveEpgPresenter', () => {
|
||||
]);
|
||||
|
||||
recentItems = signal<PortalActivityItem[]>([]);
|
||||
favoriteLiveItems = signal<PortalActivityItem[]>([]);
|
||||
recentLiveItems = signal<PortalActivityItem[]>([]);
|
||||
dashboardRails = signal({ ...DEFAULT_DASHBOARD_RAILS_SETTINGS });
|
||||
portal = {
|
||||
connect: jest.fn(),
|
||||
@@ -96,8 +100,8 @@ describe('DashboardLiveEpgPresenter', () => {
|
||||
playlists,
|
||||
// The presenter also derives the portal source list.
|
||||
globalRecentItems: recentItems,
|
||||
globalFavoriteLiveItems: signal([]),
|
||||
globalRecentLiveItems: signal([]),
|
||||
globalFavoriteLiveItems: favoriteLiveItems,
|
||||
globalRecentLiveItems: recentLiveItems,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -177,30 +181,75 @@ describe('DashboardLiveEpgPresenter', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('pins the hero row only while the hero rail shows one', () => {
|
||||
const heroLive = {
|
||||
id: 'x-7',
|
||||
title: 'Hero channel',
|
||||
type: 'live',
|
||||
source: 'xtream',
|
||||
playlist_id: 'p',
|
||||
category_id: '1',
|
||||
xtream_id: 7,
|
||||
} as PortalActivityItem;
|
||||
recentItems.set([heroLive]);
|
||||
it('pins the hero live candidates only while the hero is enabled', () => {
|
||||
const live = (id: number, title: string) =>
|
||||
({
|
||||
id: `x-${id}`,
|
||||
title,
|
||||
type: 'live',
|
||||
source: 'xtream',
|
||||
playlist_id: 'p',
|
||||
category_id: '1',
|
||||
xtream_id: id,
|
||||
}) as PortalActivityItem;
|
||||
favoriteLiveItems.set([live(7, 'Favourite channel')]);
|
||||
recentLiveItems.set([live(8, 'Recent channel'), live(7, 'Dup')]);
|
||||
TestBed.tick();
|
||||
|
||||
expect(portal.setPinnedKeys).toHaveBeenLastCalledWith(['xtream::p::7']);
|
||||
// Favourites first, then recent channels, each channel once.
|
||||
expect(
|
||||
presenter
|
||||
.heroLiveCandidates()
|
||||
.map(({ origin, item }) => [origin, item.title])
|
||||
).toEqual([
|
||||
['favorite', 'Favourite channel'],
|
||||
['recent', 'Recent channel'],
|
||||
]);
|
||||
expect(portal.setPinnedKeys).toHaveBeenLastCalledWith([
|
||||
'xtream::p::7',
|
||||
'xtream::p::8',
|
||||
]);
|
||||
|
||||
// With the rail hidden nothing is pinned: the first portal row is
|
||||
// then a favourite, and pinning it would keep asking for a card
|
||||
// nobody can see.
|
||||
// With the hero hidden nothing is pinned: a rail card nobody can
|
||||
// see must not keep the portal queue busy.
|
||||
dashboardRails.set({
|
||||
...DEFAULT_DASHBOARD_RAILS_SETTINGS,
|
||||
hero: false,
|
||||
});
|
||||
TestBed.tick();
|
||||
expect(portal.setPinnedKeys).toHaveBeenLastCalledWith([null]);
|
||||
expect(presenter.heroLiveCandidates()).toEqual([]);
|
||||
expect(portal.setPinnedKeys).toHaveBeenLastCalledWith([]);
|
||||
});
|
||||
|
||||
it('looks up hero candidates even when no live rail is connected', () => {
|
||||
getCurrentProgramsForChannels.mockImplementation(() =>
|
||||
of(
|
||||
new Map<string, EpgProgram | null>([
|
||||
['ard.de', program('Tagesschau')],
|
||||
])
|
||||
)
|
||||
);
|
||||
const channel = {
|
||||
id: 'ard-hd',
|
||||
title: 'Das Erste HD',
|
||||
type: 'live',
|
||||
source: 'm3u',
|
||||
playlist_id: 'a',
|
||||
category_id: '',
|
||||
xtream_id: 'ard-hd',
|
||||
epg_lookup_key: 'ard.de',
|
||||
} as PortalActivityItem;
|
||||
favoriteLiveItems.set([channel]);
|
||||
|
||||
setup([]);
|
||||
|
||||
expect(getCurrentProgramsForChannels).toHaveBeenCalledWith(
|
||||
['ard.de'],
|
||||
expect.objectContaining({ sourceUrls: [guideA] })
|
||||
);
|
||||
expect(presenter.heroDetailsFor(channel)?.nowPlayingTitle).toBe(
|
||||
'Tagesschau'
|
||||
);
|
||||
});
|
||||
|
||||
it('prefers the portal answer and forwards what the portal presenter owns', () => {
|
||||
|
||||
@@ -44,6 +44,10 @@ import {
|
||||
type DashboardLiveEpgLookupGroup,
|
||||
} from './dashboard-live-epg.utils';
|
||||
import { RAIL_ITEM_LIMIT } from './dashboard-rail.utils';
|
||||
import {
|
||||
selectDashboardHeroLiveCandidates,
|
||||
type DashboardHeroLiveCandidate,
|
||||
} from './dashboard-hero-slides.utils';
|
||||
|
||||
type ScopeAnswer = {
|
||||
readonly scopeKey: string;
|
||||
@@ -105,9 +109,34 @@ export class DashboardLiveEpgPresenter {
|
||||
return byPlaylistId;
|
||||
});
|
||||
|
||||
private readonly rails = computed(() =>
|
||||
normalizeDashboardRailsSettings(this.settingsStore.dashboardRails?.())
|
||||
);
|
||||
|
||||
/**
|
||||
* Channels that may fill the hero's live slide. Looked up and pinned
|
||||
* here, independent of the rails, so the slide works with the live rails
|
||||
* hidden and never waits for a rail to scroll a card into view.
|
||||
*/
|
||||
readonly heroLiveCandidates = computed<DashboardHeroLiveCandidate[]>(() =>
|
||||
this.rails().hero
|
||||
? selectDashboardHeroLiveCandidates(
|
||||
this.data.globalFavoriteLiveItems(),
|
||||
this.data.globalRecentLiveItems()
|
||||
)
|
||||
: []
|
||||
);
|
||||
|
||||
private readonly heroLiveCards = computed(() =>
|
||||
this.heroLiveCandidates().map(({ item }) =>
|
||||
buildDashboardLiveEpgCard(item)
|
||||
)
|
||||
);
|
||||
|
||||
private readonly lookupGroups = computed(() =>
|
||||
buildLiveEpgLookupGroups(this.cards()?.() ?? [], (card) =>
|
||||
this.sourceUrlsForCard(card)
|
||||
buildLiveEpgLookupGroups(
|
||||
[...this.heroLiveCards(), ...(this.cards()?.() ?? [])],
|
||||
(card) => this.sourceUrlsForCard(card)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -131,25 +160,14 @@ export class DashboardLiveEpgPresenter {
|
||||
{ initialValue: new Map<string, EpgProgram | null>() }
|
||||
);
|
||||
|
||||
private readonly rails = computed(() =>
|
||||
normalizeDashboardRailsSettings(this.settingsStore.dashboardRails?.())
|
||||
);
|
||||
|
||||
/** The live row behind the hero panel, when that rail shows one. */
|
||||
private readonly heroLiveItem = computed<PortalActivityItem | null>(() => {
|
||||
const hero = this.data.globalRecentItems()[0] ?? null;
|
||||
return this.rails().hero && hero?.type === 'live' ? hero : null;
|
||||
});
|
||||
|
||||
// The Xtream/Stalker live rows behind the hero and the two live rails.
|
||||
// Their programmes come from the portal, asked for lazily per visible
|
||||
// card; M3U rows stay on the XMLTV batch above.
|
||||
private readonly portalItems = computed<readonly PortalActivityItem[]>(
|
||||
() => {
|
||||
const rails = this.rails();
|
||||
const hero = this.heroLiveItem();
|
||||
return [
|
||||
...(hero ? [hero] : []),
|
||||
...this.heroLiveCandidates().map(({ item }) => item),
|
||||
...(rails.liveFavorites
|
||||
? this.data
|
||||
.globalFavoriteLiveItems()
|
||||
@@ -167,18 +185,17 @@ export class DashboardLiveEpgPresenter {
|
||||
constructor() {
|
||||
this.portal.connect(this.portalItems);
|
||||
// The hero sits at the top of the page and is never scrolled into
|
||||
// view, so its key is wanted regardless of what the rails report.
|
||||
// Only the hero: the first entry of `portalItems` is a favourite
|
||||
// when that rail is hidden, and pinning it would keep asking for a
|
||||
// card nobody can see.
|
||||
// view, so its candidates are wanted regardless of what the rails
|
||||
// report. Only those: a rail card nobody can see stays unpinned.
|
||||
effect(() => {
|
||||
const hero = this.heroLiveItem();
|
||||
const heroKey = hero ? buildDashboardPortalLiveEpgKey(hero) : null;
|
||||
untracked(() => this.portal.setPinnedKeys([heroKey]));
|
||||
const keys = this.heroLiveCandidates().map(({ item }) =>
|
||||
buildDashboardPortalLiveEpgKey(item)
|
||||
);
|
||||
untracked(() => this.portal.setPinnedKeys(keys));
|
||||
});
|
||||
}
|
||||
|
||||
/** The live cards whose rails are enabled, hero included. */
|
||||
/** The live cards whose rails are enabled (hero candidates are added here). */
|
||||
connect(cards: Signal<readonly DashboardRailCard[]>): void {
|
||||
this.cards.set(cards);
|
||||
}
|
||||
@@ -206,6 +223,11 @@ export class DashboardLiveEpgPresenter {
|
||||
});
|
||||
}
|
||||
|
||||
/** Current programme of a hero live candidate, or `null`. */
|
||||
heroDetailsFor(item: PortalActivityItem): DashboardLiveEpgDetails | null {
|
||||
return this.detailsFor(buildDashboardLiveEpgCard(item));
|
||||
}
|
||||
|
||||
/** `null` when nothing is known about the card's current programme. */
|
||||
detailsFor(card: DashboardRailCard | null): DashboardLiveEpgDetails | null {
|
||||
if (!card) {
|
||||
@@ -285,3 +307,19 @@ function mergeAnswers(
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** The fields the EPG lookups read, for a live row that has no rail card. */
|
||||
function buildDashboardLiveEpgCard(
|
||||
item: PortalActivityItem
|
||||
): DashboardRailCard {
|
||||
return {
|
||||
id: `hero-live-${item.playlist_id}-${item.xtream_id ?? item.id}`,
|
||||
title: item.title,
|
||||
icon: 'live_tv',
|
||||
contentType: 'live',
|
||||
link: [],
|
||||
epgLookupKey: item.epg_lookup_key,
|
||||
epgPlaylistId: item.playlist_id,
|
||||
liveEpgSourceKey: buildDashboardPortalLiveEpgKey(item),
|
||||
};
|
||||
}
|
||||
@@ -74,6 +74,10 @@ export interface DashboardLiveEpgDetails {
|
||||
readonly nowPlayingTitle: string | null;
|
||||
readonly nowPlayingTimeRange: string | null;
|
||||
readonly nowPlayingProgress: number | null;
|
||||
/** Programme synopsis; set only when the guide has one (hero slide). */
|
||||
readonly nowPlayingDescription?: string;
|
||||
/** Guide category ("Sport"); set only when the guide has one. */
|
||||
readonly nowPlayingCategory?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,6 +94,8 @@ export function buildDashboardLiveEpgDetails(
|
||||
return null;
|
||||
}
|
||||
|
||||
const description = program.desc?.trim();
|
||||
const category = program.category?.trim();
|
||||
const details: DashboardLiveEpgDetails = {
|
||||
nowPlayingTitle: program.title?.trim() || null,
|
||||
nowPlayingTimeRange: formatEpgTimeRange(program, offsetMinutes),
|
||||
@@ -97,6 +103,8 @@ export function buildDashboardLiveEpgDetails(
|
||||
program,
|
||||
epgProviderClockMs(nowMs, offsetMinutes)
|
||||
),
|
||||
...(description ? { nowPlayingDescription: description } : {}),
|
||||
...(category ? { nowPlayingCategory: category } : {}),
|
||||
};
|
||||
|
||||
return details.nowPlayingTitle ||
|
||||
@@ -204,17 +212,19 @@ export function buildLiveEpgLookupGroups(
|
||||
|
||||
type DashboardLiveEpgRailSettings = Pick<
|
||||
DashboardRailsSettings,
|
||||
'hero' | 'liveFavorites' | 'recentlyWatchedLive'
|
||||
'liveFavorites' | 'recentlyWatchedLive'
|
||||
>;
|
||||
|
||||
/**
|
||||
* The rails' live cards whose rails are enabled. Hero candidates are not
|
||||
* passed here: `DashboardLiveEpgPresenter` derives and pins them itself.
|
||||
*/
|
||||
export function buildLiveEpgCardsForEnabledRails(
|
||||
rails: DashboardLiveEpgRailSettings,
|
||||
heroLiveCard: DashboardRailCard | null,
|
||||
liveFavoriteCards: readonly DashboardRailCard[],
|
||||
recentLiveCards: readonly DashboardRailCard[]
|
||||
): DashboardRailCard[] {
|
||||
return [
|
||||
...(rails.hero && heroLiveCard ? [heroLiveCard] : []),
|
||||
...(rails.liveFavorites ? liveFavoriteCards : []),
|
||||
...(rails.recentlyWatchedLive ? recentLiveCards : []),
|
||||
];
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
resolvePortalActivityWatchKind,
|
||||
type PlaybackPositionData,
|
||||
type PortalActivityItem,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { GlobalRecentItem } from '@iptvnator/workspace/dashboard/data-access';
|
||||
|
||||
export interface DashboardRemainingLabel {
|
||||
@@ -83,3 +88,23 @@ export function formatRemainingLabel(
|
||||
params: { hours, minutes },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* "S1·E5" for an item whose progress is tracked per episode. Keyed on the
|
||||
* WATCH kind: a Stalker embedded-VOD / lazy `is_series` show routes as a
|
||||
* movie but still names the episode it is on.
|
||||
*/
|
||||
export function buildDashboardEpisodeBadge(
|
||||
item: Pick<PortalActivityItem, 'type' | 'watch_kind'>,
|
||||
position: PlaybackPositionData | null,
|
||||
translate: (key: string, params: Record<string, number>) => string
|
||||
): string | null {
|
||||
return resolvePortalActivityWatchKind(item) === 'series' &&
|
||||
position?.seasonNumber != null &&
|
||||
position?.episodeNumber != null
|
||||
? translate('WORKSPACE.DASHBOARD.SEASON_EPISODE_BADGE', {
|
||||
season: position.seasonNumber,
|
||||
episode: position.episodeNumber,
|
||||
})
|
||||
: null;
|
||||
}
|
||||
+5
-191
@@ -10,198 +10,12 @@
|
||||
(addPlaylistClicked)="onAddPlaylist($event)"
|
||||
/>
|
||||
} @else {
|
||||
<!-- Hero: real card if a recent item resolved, skeleton while still
|
||||
loading the first global recent item, nothing if no recent items
|
||||
exist. Each block below renders as soon as ITS data is ready —
|
||||
no longer pinned on the slowest rail's I/O. -->
|
||||
<!-- Hero: rotating cinematic banner. Renders its own skeleton while
|
||||
the first history load runs, and nothing when there is nothing
|
||||
to feature. Each block below renders as soon as ITS data is
|
||||
ready — no longer pinned on the slowest rail's I/O. -->
|
||||
@if (dashboardRails().hero) {
|
||||
@if (hero(); as item) {
|
||||
<a
|
||||
class="hero"
|
||||
data-test-id="dashboard-hero"
|
||||
[class.hero--with-image]="
|
||||
!!item.posterUrl || !!item.backdropUrl
|
||||
"
|
||||
[class.hero--has-backdrop]="item.hasBackdrop"
|
||||
[class.hero--has-default-backdrop]="
|
||||
item.backdropSource === 'fallback'
|
||||
"
|
||||
[class.hero--live]="item.contentType === 'live'"
|
||||
[routerLink]="item.link"
|
||||
[state]="item.state ?? {}"
|
||||
>
|
||||
<span
|
||||
class="hero__backdrop"
|
||||
aria-hidden="true"
|
||||
[style.backgroundImage]="
|
||||
item.backdropUrl
|
||||
? 'url(' + item.backdropUrl + ')'
|
||||
: item.fallbackBackdropBackground
|
||||
"
|
||||
></span>
|
||||
@if (item.backdropUrl; as backdropUrl) {
|
||||
<img
|
||||
class="hero__image-probe"
|
||||
[src]="backdropUrl"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
(error)="markHeroImageFailed(backdropUrl)"
|
||||
/>
|
||||
}
|
||||
<span class="hero__vignette" aria-hidden="true"></span>
|
||||
|
||||
<span class="hero__poster">
|
||||
@if (item.posterUrl; as posterUrl) {
|
||||
<img
|
||||
[src]="posterUrl"
|
||||
[alt]="item.title"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
(error)="markHeroImageFailed(posterUrl)"
|
||||
/>
|
||||
} @else {
|
||||
<span
|
||||
class="hero__poster-fallback"
|
||||
[style.backgroundImage]="
|
||||
item.fallbackPosterBackground
|
||||
"
|
||||
>
|
||||
<mat-icon>{{ item.icon }}</mat-icon>
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
|
||||
<span class="hero__body">
|
||||
<h1 class="hero__title">{{ item.title }}</h1>
|
||||
<p class="hero__subtitle">{{ item.subtitle }}</p>
|
||||
|
||||
@if (
|
||||
item.episodeBadge ||
|
||||
item.rating ||
|
||||
(item.genres && item.genres.length > 0)
|
||||
) {
|
||||
<span
|
||||
class="hero__badges"
|
||||
data-test-id="dashboard-hero-badges"
|
||||
>
|
||||
@if (item.episodeBadge) {
|
||||
<span
|
||||
class="hero__badge hero__badge--episode"
|
||||
>{{ item.episodeBadge }}</span
|
||||
>
|
||||
}
|
||||
@if (item.rating) {
|
||||
<span
|
||||
class="hero__badge hero__badge--rating"
|
||||
>★ {{ item.rating }}</span
|
||||
>
|
||||
}
|
||||
@for (genre of item.genres; track genre) {
|
||||
<span class="hero__badge">{{ genre }}</span>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
|
||||
@if (
|
||||
item.contentType === 'live' &&
|
||||
(item.nowPlayingTitle ||
|
||||
item.nowPlayingTimeRange ||
|
||||
(item.nowPlayingProgress !== null &&
|
||||
item.nowPlayingProgress !== undefined))
|
||||
) {
|
||||
<span
|
||||
class="hero__live-epg"
|
||||
data-test-id="dashboard-hero-live-epg"
|
||||
>
|
||||
<span class="hero__live-epg-topline">
|
||||
<span class="hero__live-epg-chip"
|
||||
>LIVE</span
|
||||
>
|
||||
@if (item.nowPlayingTimeRange) {
|
||||
<span class="hero__live-epg-time">{{
|
||||
item.nowPlayingTimeRange
|
||||
}}</span>
|
||||
}
|
||||
</span>
|
||||
@if (item.nowPlayingTitle) {
|
||||
<span class="hero__live-epg-title">{{
|
||||
item.nowPlayingTitle
|
||||
}}</span>
|
||||
}
|
||||
<span
|
||||
class="hero__live-epg-progress"
|
||||
[class.hero__live-epg-progress--idle]="
|
||||
item.nowPlayingProgress === null ||
|
||||
item.nowPlayingProgress === undefined
|
||||
"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<i
|
||||
[style.width.%]="
|
||||
item.nowPlayingProgress ?? 0
|
||||
"
|
||||
></i>
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
|
||||
@if (
|
||||
item.watchProgress !== null &&
|
||||
item.watchProgress !== undefined
|
||||
) {
|
||||
<span class="hero__progress" aria-hidden="true">
|
||||
<span
|
||||
class="hero__progress-bar"
|
||||
[style.width.%]="item.watchProgress"
|
||||
></span>
|
||||
</span>
|
||||
<span class="hero__progress-meta">
|
||||
@if (item.remainingLabel) {
|
||||
<span>{{
|
||||
item.remainingLabel.key
|
||||
| translate
|
||||
: item.remainingLabel.params
|
||||
}}</span>
|
||||
<span class="hero__progress-meta-sep"
|
||||
>·</span
|
||||
>
|
||||
}
|
||||
<span>{{
|
||||
'WORKSPACE.DASHBOARD.PERCENT_WATCHED'
|
||||
| translate
|
||||
: { value: item.watchProgress }
|
||||
}}</span>
|
||||
</span>
|
||||
}
|
||||
|
||||
<span class="hero__cta">
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
<span>{{
|
||||
'WORKSPACE.DASHBOARD.CONTINUE_WATCHING'
|
||||
| translate
|
||||
}}</span>
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
} @else if (data.globalRecentLoading()) {
|
||||
<section
|
||||
class="rails-page__skeleton-hero"
|
||||
aria-hidden="true"
|
||||
aria-busy="true"
|
||||
>
|
||||
<div class="rails-page__skeleton-hero-poster"></div>
|
||||
<div class="rails-page__skeleton-hero-body">
|
||||
<div class="rails-page__skeleton-hero-title"></div>
|
||||
<div
|
||||
class="rails-page__skeleton-hero-title rails-page__skeleton-hero-title--short"
|
||||
></div>
|
||||
<div class="rails-page__skeleton-hero-subtitle"></div>
|
||||
<div class="rails-page__skeleton-hero-cta"></div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
<lib-dashboard-hero />
|
||||
}
|
||||
|
||||
<!-- Continue watching (movies + series) -->
|
||||
|
||||
+12
-582
@@ -1,8 +1,14 @@
|
||||
:host {
|
||||
// Read by the full-bleed hero to cancel the gutters and centring out.
|
||||
--dashboard-gutter: 28px;
|
||||
--dashboard-top-gutter: 24px;
|
||||
--dashboard-max-width: 1440px;
|
||||
|
||||
display: block;
|
||||
padding: 24px 28px 40px;
|
||||
padding: var(--dashboard-top-gutter) var(--dashboard-gutter) 40px;
|
||||
box-sizing: border-box;
|
||||
min-height: 100%;
|
||||
container: dashboard / inline-size;
|
||||
}
|
||||
|
||||
:host(.rails-page-host--empty) {
|
||||
@@ -14,7 +20,7 @@
|
||||
}
|
||||
|
||||
.rails-page {
|
||||
max-width: 1440px;
|
||||
max-width: var(--dashboard-max-width);
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -32,504 +38,6 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// ── Hero ────────────────────────────────────────────────────────────────────
|
||||
// Netflix/Apple TV-style hero: blurred poster backdrop + vignette + sharp 2:3
|
||||
// poster card + text block. Mirrors content-hero fallback (no dedicated
|
||||
// backdrop asset available, so we reuse the poster blurred + scaled).
|
||||
.hero {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, var(--hero-poster-width)) minmax(0, 1fr);
|
||||
gap: 20px;
|
||||
padding: 16px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
background: var(--app-widget-header-bg, #232840);
|
||||
border: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.08));
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.2),
|
||||
0 12px 32px -18px var(--app-selection-glow, rgba(120, 173, 255, 0.4));
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
|
||||
// Tighter than v0.21 (was 160px). Matches the v0.22 mockup's 150px
|
||||
// poster intent, freeing space for the progress + remaining label
|
||||
// without growing total hero height.
|
||||
--hero-poster-width: 140px;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.2),
|
||||
0 20px 40px -20px
|
||||
var(--app-selection-glow, rgba(120, 173, 255, 0.55));
|
||||
}
|
||||
|
||||
// No-image fallback keeps the accent gradient so the hero is never flat.
|
||||
&:not(.hero--with-image) {
|
||||
background:
|
||||
var(
|
||||
--app-accent-gradient,
|
||||
linear-gradient(
|
||||
135deg,
|
||||
rgba(99, 102, 241, 0.12) 0%,
|
||||
rgba(168, 85, 247, 0.07) 100%
|
||||
)
|
||||
),
|
||||
var(--app-widget-header-bg, #232840);
|
||||
}
|
||||
}
|
||||
|
||||
// Layer 0: backdrop. Two modes:
|
||||
// (1) Real backdrop (`.hero--has-backdrop`): sharp wide image with no blur.
|
||||
// (2) Blurred-poster fallback: heavy blur + scale so the poster's colours
|
||||
// wash across the full panel without the portrait edges showing.
|
||||
.hero__backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-size: cover;
|
||||
background-position: center 25%;
|
||||
background-repeat: no-repeat;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
|
||||
// Fallback: blurred poster.
|
||||
filter: blur(40px) saturate(1.5);
|
||||
transform: scale(1.5);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.hero--has-backdrop .hero__backdrop {
|
||||
filter: none;
|
||||
transform: none;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.hero--has-default-backdrop .hero__backdrop {
|
||||
filter: none;
|
||||
transform: none;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.hero__image-probe {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
clip-path: inset(50%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// Layer 1: vignette. Scrim behind the poster card (contrast) and on the
|
||||
// right edge (text legibility). Middle stays mostly transparent so the
|
||||
// backdrop colours show through. Darkens further when a sharp backdrop is
|
||||
// present, since real photography has more focal variation than a blur wash.
|
||||
.hero__vignette {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--app-widget-header-bg, #232840) 55%,
|
||||
transparent
|
||||
)
|
||||
0%,
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--app-widget-header-bg, #232840) 10%,
|
||||
transparent
|
||||
)
|
||||
45%,
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--app-widget-header-bg, #232840) 65%,
|
||||
transparent
|
||||
)
|
||||
100%
|
||||
),
|
||||
linear-gradient(
|
||||
to bottom,
|
||||
transparent 55%,
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--app-widget-header-bg, #232840) 35%,
|
||||
transparent
|
||||
)
|
||||
100%
|
||||
);
|
||||
}
|
||||
|
||||
.hero--has-backdrop .hero__vignette {
|
||||
background:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
rgba(0, 0, 0, 0.55) 0%,
|
||||
rgba(0, 0, 0, 0.2) 35%,
|
||||
rgba(0, 0, 0, 0.78) 100%
|
||||
),
|
||||
linear-gradient(
|
||||
to bottom,
|
||||
rgba(0, 0, 0, 0) 40%,
|
||||
rgba(0, 0, 0, 0.45) 100%
|
||||
);
|
||||
}
|
||||
|
||||
// Layer 2: sharp poster in native 2:3 aspect (no more 16:9 crop).
|
||||
.hero__poster {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: var(--hero-poster-width);
|
||||
aspect-ratio: 2 / 3;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: var(--app-widget-bg, #1e2330);
|
||||
border: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.1));
|
||||
box-shadow: 0 8px 24px -10px rgba(0, 0, 0, 0.55);
|
||||
align-self: center;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.hero__poster-fallback {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: radial-gradient(
|
||||
circle at 50% 40%,
|
||||
var(--app-selection-surface, rgba(120, 173, 255, 0.2)) 0%,
|
||||
transparent 60%
|
||||
);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
|
||||
mat-icon {
|
||||
font-size: 56px;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
color: var(--app-selection-color, #78adff);
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
|
||||
// Live channel logos (small square images) get destroyed by the default
|
||||
// `object-fit: cover` poster treatment. The `.hero--live` modifier centers
|
||||
// the logo with breathing room over a tinted backdrop. The blurred
|
||||
// `.hero__backdrop` already provides the ambient color wash for the wider hero.
|
||||
.hero--live .hero__poster {
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 50% 35%,
|
||||
var(--app-selection-surface, rgba(120, 173, 255, 0.18)) 0%,
|
||||
transparent 65%
|
||||
),
|
||||
var(--app-widget-bg, #1e2330);
|
||||
|
||||
img {
|
||||
box-sizing: border-box;
|
||||
object-fit: contain;
|
||||
padding: 22% 18%;
|
||||
filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.45));
|
||||
}
|
||||
}
|
||||
|
||||
.hero__body {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hero__title {
|
||||
margin: 6px 0 4px;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.15;
|
||||
// Display-weight tracking: looser than -0.02em which crushes glyphs at
|
||||
// this size (~28px). Matches Apple TV+ "Up Next" card scale.
|
||||
letter-spacing: -0.015em;
|
||||
color: var(--app-heading-color, #d8dce8);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
|
||||
.hero--with-image & {
|
||||
color: #fff;
|
||||
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
}
|
||||
|
||||
.hero__subtitle {
|
||||
margin: 0;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.45;
|
||||
letter-spacing: 0.002em;
|
||||
color: var(--app-body-color, #8b93a8);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
.hero--with-image & {
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
}
|
||||
|
||||
.hero__badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.hero__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--app-body-color, #8b93a8);
|
||||
background: rgba(128, 138, 160, 0.14);
|
||||
border: 1px solid rgba(128, 138, 160, 0.22);
|
||||
|
||||
.hero--with-image & {
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
&--rating {
|
||||
color: #ffd166;
|
||||
|
||||
.hero--with-image & {
|
||||
color: #ffd166;
|
||||
}
|
||||
}
|
||||
|
||||
&--episode {
|
||||
color: #7ddb8a;
|
||||
|
||||
.hero--with-image & {
|
||||
color: #7ddb8a;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hero__live-epg {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
width: min(440px, 100%);
|
||||
margin-top: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hero__live-epg-topline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hero__live-epg-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
flex: 0 0 auto;
|
||||
font-family: var(
|
||||
--font-mono,
|
||||
ui-monospace,
|
||||
'SF Mono',
|
||||
'JetBrains Mono',
|
||||
Menlo,
|
||||
monospace
|
||||
);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
letter-spacing: 0;
|
||||
color: var(--app-live-color, #ff8f8f);
|
||||
text-transform: uppercase;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--app-live-color, #ff8f8f);
|
||||
box-shadow: 0 0 7px var(--app-live-color, #ff8f8f);
|
||||
}
|
||||
}
|
||||
|
||||
.hero__live-epg-time {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--app-muted-color, #8891a4);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
.hero--with-image & {
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
}
|
||||
|
||||
.hero__live-epg-title {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--app-heading-color, #d8dce8);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 650;
|
||||
line-height: 1.25;
|
||||
|
||||
.hero--with-image & {
|
||||
color: rgba(255, 255, 255, 0.96);
|
||||
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.hero__live-epg-progress {
|
||||
position: relative;
|
||||
display: block;
|
||||
height: 4px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface, #e6e1e5) 14%,
|
||||
transparent
|
||||
);
|
||||
|
||||
.hero--with-image & {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--app-live-color, #ff8f8f);
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
&--idle i {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface, #e6e1e5) 22%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Watch-progress visualisation. Appears between the subtitle and the CTA
|
||||
// only when a resume position is known — VOD/series only (M3U and live
|
||||
// channels have no playback positions in the schema).
|
||||
.hero__progress {
|
||||
display: block;
|
||||
position: relative;
|
||||
margin-top: 12px;
|
||||
height: 4px;
|
||||
width: min(320px, 70%);
|
||||
border-radius: 999px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface, #e6e1e5) 14%,
|
||||
transparent
|
||||
);
|
||||
overflow: hidden;
|
||||
|
||||
.hero--with-image & {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
}
|
||||
|
||||
.hero__progress-bar {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--app-selection-color, #78adff);
|
||||
border-radius: 999px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
.hero__progress-meta {
|
||||
margin-top: 6px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 500;
|
||||
color: var(--app-body-color, #8b93a8);
|
||||
letter-spacing: 0.005em;
|
||||
|
||||
.hero--with-image & {
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
}
|
||||
|
||||
.hero__progress-meta-sep {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.hero__cta {
|
||||
margin-top: 14px;
|
||||
align-self: flex-start;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 20px 10px 14px;
|
||||
border-radius: 999px;
|
||||
background: var(--app-selection-color, #78adff);
|
||||
color: var(--app-selection-on-color, #071225);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.005em;
|
||||
// Explicit min-height anchors the text baseline regardless of font-smoothing
|
||||
min-height: 36px;
|
||||
box-sizing: border-box;
|
||||
box-shadow: 0 4px 12px -4px
|
||||
var(--app-selection-glow, rgba(120, 173, 255, 0.5));
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Loading skeleton ────────────────────────────────────────────────────────
|
||||
// Geometry mirrors the real hero + rails so the layout doesn't shift when
|
||||
// data arrives. All shimmer targets share the `%skeleton-shimmer` placeholder
|
||||
@@ -555,61 +63,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── Hero ghost ──────────────────────────────────────────────────────────────
|
||||
.rails-page__skeleton-hero {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 160px) minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
padding: 20px;
|
||||
border-radius: 18px;
|
||||
background: var(--app-widget-header-bg, #232840);
|
||||
border: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
|
||||
.rails-page__skeleton-hero-poster {
|
||||
@extend %skeleton-shimmer;
|
||||
width: 160px;
|
||||
aspect-ratio: 2 / 3;
|
||||
border-radius: 12px;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.rails-page__skeleton-hero-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rails-page__skeleton-hero-title {
|
||||
@extend %skeleton-shimmer;
|
||||
width: 60%;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.rails-page__skeleton-hero-title--short {
|
||||
width: 35%;
|
||||
}
|
||||
|
||||
.rails-page__skeleton-hero-subtitle {
|
||||
@extend %skeleton-shimmer;
|
||||
width: 45%;
|
||||
height: 14px;
|
||||
border-radius: 4px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.rails-page__skeleton-hero-cta {
|
||||
@extend %skeleton-shimmer;
|
||||
width: 160px;
|
||||
height: 36px;
|
||||
border-radius: 999px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
// ── Rail ghost ──────────────────────────────────────────────────────────────
|
||||
.rails-page__skeleton-rail {
|
||||
display: flex;
|
||||
@@ -643,10 +96,6 @@
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.rails-page__skeleton-hero-poster,
|
||||
.rails-page__skeleton-hero-title,
|
||||
.rails-page__skeleton-hero-subtitle,
|
||||
.rails-page__skeleton-hero-cta,
|
||||
.rails-page__skeleton-header,
|
||||
.rails-page__skeleton-card {
|
||||
animation: none;
|
||||
@@ -657,37 +106,18 @@
|
||||
// ── Responsive ──────────────────────────────────────────────────────────────
|
||||
@media (max-width: 720px) {
|
||||
:host {
|
||||
padding: 16px 16px 32px;
|
||||
--dashboard-gutter: 16px;
|
||||
--dashboard-top-gutter: 16px;
|
||||
|
||||
padding-bottom: 32px;
|
||||
}
|
||||
|
||||
.rails-page {
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
--hero-poster-width: 120px;
|
||||
grid-template-columns: var(--hero-poster-width) minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
// Skeleton hero mirrors real hero at this breakpoint.
|
||||
.rails-page__skeleton-hero {
|
||||
grid-template-columns: 120px minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.rails-page__skeleton-hero-poster {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.rails-page__skeleton-card {
|
||||
flex: 0 0 min(var(--cover-rail-width, 172px), 140px);
|
||||
width: min(var(--cover-rail-width, 172px), 140px);
|
||||
}
|
||||
|
||||
.hero__title {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
}
|
||||
+36
-25
@@ -289,6 +289,30 @@ describe('EPG enrichment helpers', () => {
|
||||
nowPlayingProgress: 25,
|
||||
});
|
||||
});
|
||||
|
||||
it('adds the synopsis and category only when the guide has them', () => {
|
||||
const now = START_S * 1000;
|
||||
|
||||
expect(
|
||||
buildDashboardLiveEpgDetails(
|
||||
baseProgram({
|
||||
desc: ' Live from Moscow. ',
|
||||
category: 'Sport',
|
||||
}),
|
||||
now
|
||||
)
|
||||
).toMatchObject({
|
||||
nowPlayingDescription: 'Live from Moscow.',
|
||||
nowPlayingCategory: 'Sport',
|
||||
});
|
||||
|
||||
const bare = buildDashboardLiveEpgDetails(
|
||||
baseProgram({ desc: ' ', category: null }),
|
||||
now
|
||||
);
|
||||
expect(bare).not.toHaveProperty('nowPlayingDescription');
|
||||
expect(bare).not.toHaveProperty('nowPlayingCategory');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -544,29 +568,7 @@ describe('Live rail helpers', () => {
|
||||
expect(first).toBe('a::movie::10|b::series::20');
|
||||
});
|
||||
|
||||
it('omits hero cards from live EPG lookup sources when the hero rail is disabled', () => {
|
||||
const hero = channelCard({ id: 'hero', epgLookupKey: 'hero' });
|
||||
const favorite = channelCard({
|
||||
id: 'favorite',
|
||||
epgLookupKey: 'favorite',
|
||||
});
|
||||
const recent = channelCard({ id: 'recent', epgLookupKey: 'recent' });
|
||||
|
||||
expect(
|
||||
buildLiveEpgCardsForEnabledRails(
|
||||
{
|
||||
...DEFAULT_DASHBOARD_RAILS_SETTINGS,
|
||||
hero: false,
|
||||
},
|
||||
hero,
|
||||
[favorite],
|
||||
[recent]
|
||||
).map((card) => card.id)
|
||||
).toEqual(['favorite', 'recent']);
|
||||
});
|
||||
|
||||
it('omits disabled live rails from live EPG lookup sources', () => {
|
||||
const hero = channelCard({ id: 'hero', epgLookupKey: 'hero' });
|
||||
const favorite = channelCard({
|
||||
id: 'favorite',
|
||||
epgLookupKey: 'favorite',
|
||||
@@ -578,13 +580,22 @@ describe('Live rail helpers', () => {
|
||||
{
|
||||
...DEFAULT_DASHBOARD_RAILS_SETTINGS,
|
||||
liveFavorites: false,
|
||||
recentlyWatchedLive: false,
|
||||
},
|
||||
hero,
|
||||
[favorite],
|
||||
[recent]
|
||||
).map((card) => card.id)
|
||||
).toEqual(['hero']);
|
||||
).toEqual(['recent']);
|
||||
expect(
|
||||
buildLiveEpgCardsForEnabledRails(
|
||||
{
|
||||
...DEFAULT_DASHBOARD_RAILS_SETTINGS,
|
||||
liveFavorites: false,
|
||||
recentlyWatchedLive: false,
|
||||
},
|
||||
[favorite],
|
||||
[recent]
|
||||
)
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+11
-152
@@ -4,7 +4,6 @@ import {
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
@@ -13,15 +12,11 @@ import {
|
||||
isStalkerAccountPlaylist,
|
||||
isXtreamAccountPlaylist,
|
||||
normalizeDashboardRailsSettings,
|
||||
type PlaybackPositionData,
|
||||
playlistDisplayLabel,
|
||||
resolvePortalActivityWatchKind,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { Router } from '@angular/router';
|
||||
import { isPortalPlaybackWatched } from '@iptvnator/portal/shared/util';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
@@ -55,20 +50,18 @@ import {
|
||||
resolveSourceExpiryBadge,
|
||||
SOURCE_EXPIRY_TICK_MS,
|
||||
} from '@iptvnator/workspace/dashboard/data-access';
|
||||
import type { DashboardHeroTmdbExtras } from './dashboard-hero-tmdb.service';
|
||||
import { DashboardHeroTmdbService } from './dashboard-hero-tmdb.service';
|
||||
import { DashboardRailComponent } from './dashboard-rail.component';
|
||||
import type {
|
||||
DashboardRailCard,
|
||||
DashboardRailActionSelection,
|
||||
} from './dashboard-rail.component';
|
||||
import type { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import type { DashboardHeroModel } from './dashboard-hero.utils';
|
||||
import { DashboardPortalLiveEpgPresenter } from './dashboard-portal-live-epg.presenter';
|
||||
import { resolveDashboardHeroArtwork } from './dashboard-hero.utils';
|
||||
import { DashboardHeroComponent } from './dashboard-hero.component';
|
||||
import { buildLiveEpgCardsForEnabledRails } from './dashboard-live-epg.utils';
|
||||
import { DashboardLiveEpgPresenter } from './dashboard-live-epg.presenter';
|
||||
import {
|
||||
buildDashboardEpisodeBadge,
|
||||
buildPlaybackPositionReloadKey,
|
||||
formatRemainingLabel,
|
||||
isContinueWatchingRecentItem,
|
||||
@@ -94,11 +87,9 @@ import type {
|
||||
@Component({
|
||||
selector: 'lib-workspace-dashboard-rails',
|
||||
imports: [
|
||||
DashboardHeroComponent,
|
||||
DashboardRailComponent,
|
||||
EmptyStateComponent,
|
||||
MatButtonModule,
|
||||
MatIcon,
|
||||
RouterLink,
|
||||
TranslatePipe,
|
||||
],
|
||||
templateUrl: './workspace-dashboard-rails.component.html',
|
||||
@@ -130,7 +121,6 @@ export class WorkspaceDashboardRailsComponent {
|
||||
private readonly shellActions = inject(WORKSPACE_SHELL_ACTIONS);
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
private readonly heroTmdb = inject(DashboardHeroTmdbService);
|
||||
private readonly sourceExpiry = inject(DashboardSourceExpiryService);
|
||||
readonly trendingService = inject(DashboardTrendingService);
|
||||
readonly recommendationsService = inject(DashboardRecommendationsService);
|
||||
@@ -143,77 +133,10 @@ export class WorkspaceDashboardRailsComponent {
|
||||
readonly skeletonSlots = SKELETON_CARDS_PER_RAIL;
|
||||
readonly skeletonRails = SKELETON_RAILS;
|
||||
readonly liveRailTitleKeyForSource = liveRailTitleKeyForSource;
|
||||
readonly failedHeroImages = signal<Record<string, true>>({});
|
||||
readonly dashboardRails = computed(() =>
|
||||
normalizeDashboardRailsSettings(this.settingsStore.dashboardRails?.())
|
||||
);
|
||||
|
||||
private readonly heroRecentItem = computed(
|
||||
() => this.data.globalRecentItems()[0] ?? null
|
||||
);
|
||||
|
||||
/** TMDB extras for the current hero item, patched in after first paint */
|
||||
private readonly heroTmdbExtras = signal<{
|
||||
key: string;
|
||||
extras: DashboardHeroTmdbExtras | null;
|
||||
} | null>(null);
|
||||
|
||||
private readonly heroLiveCard = computed<DashboardRailCard | null>(() => {
|
||||
const item = this.heroRecentItem();
|
||||
return item?.type === 'live' ? this.toRecentCard(item) : null;
|
||||
});
|
||||
|
||||
readonly hero = computed<DashboardHeroModel | null>(() => {
|
||||
const item = this.heroRecentItem();
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Single reactive read, gated on the TMDB opt-in so cached
|
||||
// extras vanish immediately when the user opts out mid-session
|
||||
const extrasState = this.heroTmdbExtras();
|
||||
const extras =
|
||||
this.heroTmdb.isEnabled() &&
|
||||
extrasState?.key === this.heroTmdbKey(item)
|
||||
? extrasState.extras
|
||||
: null;
|
||||
const artwork = resolveDashboardHeroArtwork(
|
||||
{
|
||||
backdropUrl: item.backdrop_url || (extras?.backdropUrl ?? null),
|
||||
posterUrl: item.poster_url,
|
||||
title: item.title,
|
||||
},
|
||||
this.failedHeroImages()
|
||||
);
|
||||
|
||||
const position = this.data.getPlaybackPositionForItem(item);
|
||||
const liveEpgDetails =
|
||||
item.type === 'live'
|
||||
? this.liveEpg.detailsFor(this.heroLiveCard())
|
||||
: null;
|
||||
const episodeBadge = this.buildEpisodeBadge(item, position);
|
||||
|
||||
return {
|
||||
...artwork,
|
||||
contentType: item.type,
|
||||
icon: this.typeIcon(item.type),
|
||||
link: this.data.getRecentItemLink(item),
|
||||
state: this.data.getRecentItemNavigationState(item),
|
||||
subtitle: this.buildHeroSubtitle(item),
|
||||
title: item.title,
|
||||
rating: extras?.rating ?? null,
|
||||
genres: extras?.genres ?? [],
|
||||
episodeBadge,
|
||||
watchProgress:
|
||||
item.type === 'live' ? null : playbackProgressPercent(position),
|
||||
remainingLabel:
|
||||
item.type === 'live' ? null : formatRemainingLabel(position),
|
||||
nowPlayingTitle: liveEpgDetails?.nowPlayingTitle ?? null,
|
||||
nowPlayingTimeRange: liveEpgDetails?.nowPlayingTimeRange ?? null,
|
||||
nowPlayingProgress: liveEpgDetails?.nowPlayingProgress ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
readonly continueWatchingBaseCards = computed<DashboardRailCard[]>(() => {
|
||||
this.languageTick();
|
||||
return this.data
|
||||
@@ -255,12 +178,11 @@ export class WorkspaceDashboardRailsComponent {
|
||||
})
|
||||
);
|
||||
|
||||
// The live cards whose rails are enabled; the presenter looks their
|
||||
// programmes up per XMLTV source scope.
|
||||
// The live cards whose rails are enabled; the presenter adds the hero's
|
||||
// live candidates and looks the programmes up per XMLTV source scope.
|
||||
private readonly enabledLiveCards = computed(() =>
|
||||
buildLiveEpgCardsForEnabledRails(
|
||||
this.dashboardRails(),
|
||||
this.heroLiveCard(),
|
||||
this.liveFavoriteCards(),
|
||||
this.recentLiveCards()
|
||||
)
|
||||
@@ -405,27 +327,6 @@ export class WorkspaceDashboardRailsComponent {
|
||||
untracked(() => void this.data.reloadPlaybackPositions());
|
||||
});
|
||||
|
||||
// TMDB extras for the hero (backdrop/rating/genres) — async after
|
||||
// first paint, staleness-guarded against hero changes in flight.
|
||||
effect(() => {
|
||||
const item = this.heroRecentItem();
|
||||
if (!item || (item.type !== 'movie' && item.type !== 'series')) {
|
||||
return;
|
||||
}
|
||||
const key = this.heroTmdbKey(item);
|
||||
untracked(() => {
|
||||
if (this.heroTmdbExtras()?.key === key) {
|
||||
return;
|
||||
}
|
||||
void this.heroTmdb.getExtras(item).then((extras) => {
|
||||
const current = untracked(() => this.heroRecentItem());
|
||||
if (current && this.heroTmdbKey(current) === key) {
|
||||
this.heroTmdbExtras.set({ key, extras });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Subscription-expiry badges for the source cards. Xtream rides the
|
||||
// shared PortalStatusService cache; Stalker reads the import-time
|
||||
// snapshot (memoized per playlist), so this stays cheap on re-entry.
|
||||
@@ -478,12 +379,6 @@ export class WorkspaceDashboardRailsComponent {
|
||||
this.shellActions.openAddPlaylistDialog(type);
|
||||
}
|
||||
|
||||
markHeroImageFailed(url: string): void {
|
||||
this.failedHeroImages.update((state) =>
|
||||
state[url] ? state : { ...state, [url]: true }
|
||||
);
|
||||
}
|
||||
|
||||
onSourceActionSelected(selection: DashboardRailActionSelection): void {
|
||||
const playlist = this.data
|
||||
.playlists()
|
||||
@@ -576,46 +471,14 @@ export class WorkspaceDashboardRailsComponent {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Only the source name under the hero title. Provider kind (Xtream /
|
||||
* Stalker / M3U) and content kind (movie / series) are the app's own
|
||||
* taxonomy, not a property of the title, and the badges row already
|
||||
* says "S1·E5"; a stored playlist name can be a pasted URL with
|
||||
* credentials or a MAC, so it goes through `playlistDisplayLabel`.
|
||||
*/
|
||||
private buildHeroSubtitle(item: GlobalRecentItem): string {
|
||||
return playlistDisplayLabel(
|
||||
item.playlist_name,
|
||||
this.data.getRecentItemProviderLabel(item)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "S1·E5" for an item whose progress is tracked per episode. Keyed on
|
||||
* the WATCH kind: a Stalker embedded-VOD / lazy `is_series` show routes
|
||||
* as a movie but still names the episode it is on.
|
||||
*/
|
||||
private buildEpisodeBadge(
|
||||
item: GlobalRecentItem,
|
||||
position: PlaybackPositionData | null
|
||||
): string | null {
|
||||
return resolvePortalActivityWatchKind(item) === 'series' &&
|
||||
position?.seasonNumber != null &&
|
||||
position?.episodeNumber != null
|
||||
? this.translate.instant(
|
||||
'WORKSPACE.DASHBOARD.SEASON_EPISODE_BADGE',
|
||||
{
|
||||
season: position.seasonNumber,
|
||||
episode: position.episodeNumber,
|
||||
}
|
||||
)
|
||||
: null;
|
||||
}
|
||||
|
||||
private toRecentCard(item: GlobalRecentItem): DashboardRailCard {
|
||||
const position = this.data.getPlaybackPositionForItem(item);
|
||||
const watchProgress = playbackProgressPercent(position);
|
||||
const episodeBadge = this.buildEpisodeBadge(item, position);
|
||||
const episodeBadge = buildDashboardEpisodeBadge(
|
||||
item,
|
||||
position,
|
||||
(key, params) => this.translate.instant(key, params)
|
||||
);
|
||||
return {
|
||||
id: this.recentCardId(item),
|
||||
title: item.title,
|
||||
@@ -686,10 +549,6 @@ export class WorkspaceDashboardRailsComponent {
|
||||
};
|
||||
}
|
||||
|
||||
private heroTmdbKey(item: GlobalRecentItem): string {
|
||||
return this.heroTmdb.keyFor(item);
|
||||
}
|
||||
|
||||
private toTrendingCard(item: DashboardTrendingItem): DashboardRailCard {
|
||||
const subtitle = [
|
||||
item.year !== null ? String(item.year) : null,
|
||||
|
||||
Reference in new issue
Block a user