diff --git a/.changes/workspace-dashboard-cinematic-hero.md b/.changes/workspace-dashboard-cinematic-hero.md new file mode 100644 index 000000000..bb59bcfd4 --- /dev/null +++ b/.changes/workspace-dashboard-cinematic-hero.md @@ -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. diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 4514539d0..0877219e8 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -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", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index aebd003cf..9cbc82ade 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -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", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 6956affeb..0dbe5a074 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -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", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 2fde7bcdc..86a474657 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -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", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index bd7a280ee..546239853 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -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", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index a9c69ed3a..47985a3f1 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -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", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index e1d9fa9a5..ad7cdd9d5 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -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", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index e62de7adb..d0f14997f 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -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", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 792e33eb4..8d695043c 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -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", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index ce173034f..dcc928ee9 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -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", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 74d34ae6f..769c1b6f2 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -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", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 72ce5ee18..6d6f364ed 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -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", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index ed51d72d8..9750d29b1 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 084c14c4a..bb0aab0a8 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index f0c246995..919c838f9 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -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", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 4ea1b1456..cc40bc0fa 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -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", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index aed2740a3..845e11f02 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -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", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 51cdc5518..eca04c505 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -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", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 66b5135d4..1e8f34b9d 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -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", diff --git a/docs/architecture/tmdb-metadata-enrichment.md b/docs/architecture/tmdb-metadata-enrichment.md index 94f64cea8..74a9e72a8 100644 --- a/docs/architecture/tmdb-metadata-enrichment.md +++ b/docs/architecture/tmdb-metadata-enrichment.md @@ -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 diff --git a/docs/architecture/workspace-dashboard.md b/docs/architecture/workspace-dashboard.md index 51f61c67c..7df6c53f4 100644 --- a/docs/architecture/workspace-dashboard.md +++ b/docs/architecture/workspace-dashboard.md @@ -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 `` 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,41 @@ 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. + +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; 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 +146,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 +199,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. diff --git a/libs/workspace/dashboard/feature/README.md b/libs/workspace/dashboard/feature/README.md index 8c8fe5acd..72d0f8144 100644 --- a/libs/workspace/dashboard/feature/README.md +++ b/libs/workspace/dashboard/feature/README.md @@ -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. diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.spec.ts new file mode 100644 index 000000000..2283b8ad0 --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.spec.ts @@ -0,0 +1,330 @@ +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>; + let favorites: ReturnType>; + let addedItems: ReturnType>; + let candidates: ReturnType>; + let liveDetails: jest.Mock; + let tmdbEnabled: ReturnType>; + let getExtras: jest.Mock; + const positions = new Map([ + [ + 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) => 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); + 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('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', + }); + }); +}); diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.ts new file mode 100644 index 000000000..f4b8ebf9a --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.ts @@ -0,0 +1,354 @@ +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>({}); + private readonly tmdbExtras = signal< + ReadonlyMap + >(new Map()); + private readonly requestedTmdbKeys = new Set(); + + /** 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, + 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(() => { + this.languageTick(); + const liveDetails = this.liveSlide()?.details ?? null; + return this.sources().map((source) => + this.toSlide(source, liveDetails) + ); + }); + + constructor() { + effect(() => { + if (!this.heroTmdb.isEnabled()) { + return; + } + const items = this.sources() + .map((source) => source.item) + .filter((item) => item.type !== 'live'); + untracked(() => items.forEach((item) => this.loadTmdbExtras(item))); + }); + } + + markImageFailed(url: string): void { + this.failedImages.update((state) => + state[url] ? state : { ...state, [url]: true } + ); + } + + private loadTmdbExtras(item: PortalActivityItem): void { + const key = this.heroTmdb.keyFor(item); + 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 + * slides open the detail page; live slides open the channel. + */ + private actionsFor( + source: DashboardHeroSource, + position: PlaybackPositionData | null + ): Pick { + 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, + }; + } + 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 | 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 | undefined +): DashboardHeroAction { + return { + labelKey: 'WORKSPACE.DASHBOARD.HERO_DETAILS', + icon: 'info', + link, + state, + testId: 'dashboard-hero-primary-action', + }; +} diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.utils.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.utils.spec.ts new file mode 100644 index 000000000..ec88fc0ed --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.utils.spec.ts @@ -0,0 +1,189 @@ +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 => ({ + continueItems: [], + live: null, + favorites: [], + recentlyAdded: [], + mostRecent: null, + ...overrides, +}); + +const summary = (sources: ReturnType) => + 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('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')) + ); + }); +}); diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.utils.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.utils.ts new file mode 100644 index 000000000..b9b9f46a2 --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.utils.ts @@ -0,0 +1,140 @@ +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; + /** 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 +): 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(); + 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. + */ +export function pickDashboardHeroSources( + input: DashboardHeroSourceInput +): DashboardHeroSource[] { + const seen = new Set(); + const sources: DashboardHeroSource[] = []; + const push = (source: DashboardHeroSource | null) => { + if (!source || sources.length >= HERO_SLIDE_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 < HERO_SLIDE_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; +} diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-tmdb.service.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-tmdb.service.spec.ts index e750713da..1a692ba5f 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-tmdb.service.spec.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-tmdb.service.spec.ts @@ -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' }, + ], }; /** @@ -88,6 +91,42 @@ 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('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 +295,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); diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-tmdb.service.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-tmdb.service.ts index d01d9af63..eace0c4ee 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-tmdb.service.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-tmdb.service.ts @@ -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 @@ -103,6 +112,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 +128,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), }; } diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.html b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.html new file mode 100644 index 000000000..49f632790 --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.html @@ -0,0 +1,255 @@ +@if (activeSlide(); as active) { +
+ + + + @for (slide of [active]; track slide.id) { +
+

+ {{ slide.typeLabelKey | translate }} + + {{ slide.reasonLabelKey | translate }} +

+ +

{{ slide.title }}

+ +

+ @if (slide.episodeBadge) { + {{ slide.episodeBadge }} + } + @if (slide.rating) { + ★ {{ slide.rating }} + } + @for (genre of slide.genres; track genre) { + {{ genre }} + } + @if (slide.category) { + {{ slide.category }} + } + @if (slide.timeRange) { + {{ + slide.timeRange + }} + } + @if (slide.year) { + {{ slide.year }} + } + @if (slide.source) { + {{ + slide.source + }} + } +

+ + @if (slide.programmeTitle) { +

+ {{ slide.programmeTitle }} +

+ } + @if (slide.description) { +

{{ slide.description }}

+ } + + @if (slide.progress !== null) { + + + + } + + +
+ } + + @if (slides().length > 1) { +
+ @if (canRotate()) { + + } +
+ @for (slide of slides(); track slide.id) { + + } +
+
+ } +
+} @else if (presenter.loading()) { + +} diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.scss b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.scss new file mode 100644 index 000000000..9a5284dda --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.scss @@ -0,0 +1,660 @@ +// 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; + 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: absolute; + left: var(--hero-inset); + bottom: 34px; + z-index: 1; + 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 { + right: var(--hero-inset); + bottom: 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; + } +} diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.spec.ts new file mode 100644 index 000000000..5fd04c0b0 --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.spec.ts @@ -0,0 +1,218 @@ +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; + let slides: ReturnType>; + 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( + '[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('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('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(); + }); +}); diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.ts new file mode 100644 index 000000000..27be10ced --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.ts @@ -0,0 +1,137 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + inject, + 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'; + +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; + + private readonly activeId = signal(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 { + this.userPaused.update((paused) => !paused); + } + + 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('button')[next]?.focus(); + } +} diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.utils.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.utils.ts index 6d80b7aca..6f210ac20 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.utils.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.utils.ts @@ -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; - 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++) { diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.spec.ts index 6b5d53e2b..0aed6aaef 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.spec.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.spec.ts @@ -43,6 +43,8 @@ describe('DashboardLiveEpgPresenter', () => { let getCurrentProgramsForChannels: jest.Mock; let playlists: ReturnType>; let recentItems: ReturnType>; + let favoriteLiveItems: ReturnType>; + let recentLiveItems: ReturnType>; let dashboardRails: ReturnType< typeof signal >; @@ -74,6 +76,8 @@ describe('DashboardLiveEpgPresenter', () => { ]); recentItems = signal([]); + favoriteLiveItems = signal([]); + recentLiveItems = signal([]); 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([ + ['ard.de', program('Tagesschau')], + ]) + ) + ); + const channel = { + id: 'm-1', + title: 'Das Erste HD', + type: 'live', + source: 'm3u', + playlist_id: 'a', + category_id: '', + xtream_id: 'm-1', + 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', () => { diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.ts index 70baea7e6..019ba3e04 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.ts @@ -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(() => + 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() } ); - private readonly rails = computed(() => - normalizeDashboardRailsSettings(this.settingsStore.dashboardRails?.()) - ); - - /** The live row behind the hero panel, when that rail shows one. */ - private readonly heroLiveItem = computed(() => { - 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( () => { 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): 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), + }; +} diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.utils.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.utils.ts index 98526cabb..d674e27a9 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.utils.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.utils.ts @@ -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 : []), ]; diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-playback.utils.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-playback.utils.ts index baa0a5a75..6002e34a1 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-playback.utils.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-playback.utils.ts @@ -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, + position: PlaybackPositionData | null, + translate: (key: string, params: Record) => 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; +} diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html index 34db676c1..3e4c2f682 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html @@ -10,198 +10,12 @@ (addPlaylistClicked)="onAddPlaylist($event)" /> } @else { - + @if (dashboardRails().hero) { - @if (hero(); as item) { - - - @if (item.backdropUrl; as backdropUrl) { - - } - - - - @if (item.posterUrl; as posterUrl) { - - } @else { - - {{ item.icon }} - - } - - - -

{{ item.title }}

-

{{ item.subtitle }}

- - @if ( - item.episodeBadge || - item.rating || - (item.genres && item.genres.length > 0) - ) { - - @if (item.episodeBadge) { - {{ item.episodeBadge }} - } - @if (item.rating) { - ★ {{ item.rating }} - } - @for (genre of item.genres; track genre) { - {{ genre }} - } - - } - - @if ( - item.contentType === 'live' && - (item.nowPlayingTitle || - item.nowPlayingTimeRange || - (item.nowPlayingProgress !== null && - item.nowPlayingProgress !== undefined)) - ) { - - - LIVE - @if (item.nowPlayingTimeRange) { - {{ - item.nowPlayingTimeRange - }} - } - - @if (item.nowPlayingTitle) { - {{ - item.nowPlayingTitle - }} - } - - - } - - @if ( - item.watchProgress !== null && - item.watchProgress !== undefined - ) { - - - @if (item.remainingLabel) { - {{ - item.remainingLabel.key - | translate - : item.remainingLabel.params - }} - · - } - {{ - 'WORKSPACE.DASHBOARD.PERCENT_WATCHED' - | translate - : { value: item.watchProgress } - }} - - } - - - play_arrow - {{ - 'WORKSPACE.DASHBOARD.CONTINUE_WATCHING' - | translate - }} - -
-
- } @else if (data.globalRecentLoading()) { - - } + } diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.scss b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.scss index ccf9ddf99..077d7a999 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.scss +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.scss @@ -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; - } } diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts index 74feeb38a..6d16a412c 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts @@ -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([]); }); }); diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts index 4a6edd308..7a7f8aa86 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts @@ -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>({}); 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(() => { - const item = this.heroRecentItem(); - return item?.type === 'live' ? this.toRecentCard(item) : null; - }); - - readonly hero = computed(() => { - 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(() => { 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,