diff --git a/.changes/portals-posters-only-wall.md b/.changes/portals-posters-only-wall.md new file mode 100644 index 000000000..ffe70c2df --- /dev/null +++ b/.changes/portals-posters-only-wall.md @@ -0,0 +1,7 @@ +--- +type: feature +area: portals +highlight: Posters-only cover wall +--- + +Movie and series grids can now drop the title row: turn off "Show titles under covers" in Settings > General and the catalog, favorites and recent grids become a wall of covers that shows more rows per screen. The title slides in on the cover when you point at it or focus it with the keyboard, and stays on for items whose cover is missing. Live channels and search results keep their labels. diff --git a/CLAUDE.md b/CLAUDE.md index 39e53cd24..77bdba3c8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -531,6 +531,8 @@ The live EPG panel is a horizontal **timeline ribbon** under the player (`app-ep **Collapsible live channel rail** (M3U player, Xtream/Stalker live layouts, unified favorites/recent live tab): collapse state is owned by `LiveLayoutSidebarStateService` (`@iptvnator/portal/shared/util`) and kept per surface (`m3u` / `portal` / `collection`, localStorage `live-sidebar-state:`); the pre-split shared key `live-sidebar-state` is forgotten on startup and never read (issue #1458: one stored `collapsed` hid every channel list in the app behind a 32px chevron and survived restart, "Remove all playlists" and re-import). The workspace header renders a `view_sidebar` toggle on every route that renders its own rail (`resolveRouteLiveSidebarSurface`) so the control exists in both states, and a collapsed rail with nothing playing shows `app-channel-list-hidden-state` (title + hint + "Show channels list" button) instead of "select a channel". Contract: "Collapsible Live Sidebar" in `docs/architecture/iptvnator-ui-guidelines.md`. +**Cover grids** (Xtream/Stalker VOD + series catalogs, favorites/recent, dashboard rails): sized by `Settings.coverSize` (small/medium/large → `--cover-grid-min-width`/`--cover-rail-width`/`--cover-gap`, written to `` in `app.component.ts`, tokens in `apps/web/src/_cover-size.scss`). `Settings.showCoverTitles` (Settings → General, default on; only an explicit `false` opts out, coerced with the other default-on flags in `libs/services/src/lib/settings-opt-out.util.ts`) turns VOD/series grids into a posters-only wall: the title row is dropped and a `.cover-title-overlay` caption slides in on hover/`:focus-visible`, pinned open when the cover is missing or failed. `CoverTitlesService` (`libs/portal/shared/ui`) is the single resolver — opt-out AND a `(any-hover: hover)` pointer, so touch-only devices keep titles. Live channel grids, search results (search pages pass `[allowPostersOnly]="false"` to `app-content-card`; `app-grid-list` and `app-unified-grid-tab` keep titles while their `searchTerm` is non-blank), "recently added" rails and dashboard rails always keep their labels. Catalog and collection cards are keyboard buttons (`role="button"`, Enter/Space, focus ring). Contract: "Cover Grids" in `docs/architecture/iptvnator-ui-guidelines.md`. + **Radio Channel Layout** (when `channel.radio === 'true'`): ``` diff --git a/apps/web-e2e/src/xtream.e2e.ts b/apps/web-e2e/src/xtream.e2e.ts index bf6adec9e..a14ddd2c0 100644 --- a/apps/web-e2e/src/xtream.e2e.ts +++ b/apps/web-e2e/src/xtream.e2e.ts @@ -883,6 +883,48 @@ type XtreamRawEpgListing = { title: string; }; +test('@xtream posters-only wall — hides VOD titles behind a hover caption after Save', async ({ + page, +}) => { + await addXtreamPortal(page); + await page.locator('.context-panel .category-item').first().click(); + + const grid = page.locator('app-grid-list'); + const firstCard = grid.locator('mat-card').first(); + await expect(firstCard).toBeVisible({ timeout: 10_000 }); + await expect(firstCard.locator('.title')).toBeVisible(); + await expect(grid).not.toHaveClass(/grid-list--posters-only/); + + const catalogUrl = page.url(); + await page.goto('/workspace/settings/general'); + const toggle = page.locator('[data-test-id="cover-titles-toggle"]'); + await expect(toggle.locator('input')).toBeChecked(); + await toggle.click(); + const saveButton = page.locator('[data-test-id="save-settings"]'); + await saveButton.click(); + await expect(saveButton).toBeHidden(); + + await page.goto(catalogUrl); + await page.locator('.context-panel .category-item').first().click(); + await expect(firstCard).toBeVisible({ timeout: 10_000 }); + await expect(grid).toHaveClass(/grid-list--posters-only/); + await expect(firstCard.locator('.title')).toHaveCount(0); + + // The caption is a hover/focus reveal: hidden at rest, shown when the + // pointer or keyboard focus lands on the card. + const caption = firstCard.locator('.cover-title-overlay'); + await expect(caption).toHaveCSS('opacity', '0'); + await firstCard.hover(); + await expect(caption).toHaveCSS('opacity', '1'); + await page.mouse.move(0, 0); + await expect(caption).toHaveCSS('opacity', '0'); + await firstCard.focus(); + await page.keyboard.press('Tab'); + await page.keyboard.press('Shift+Tab'); + await expect(firstCard).toBeFocused(); + await expect(caption).toHaveCSS('opacity', '1'); +}); + async function getEpgFixtureStream( request: APIRequestContext ): Promise { diff --git a/apps/web/src/app/settings/settings-form.utils.ts b/apps/web/src/app/settings/settings-form.utils.ts index 57c38eb41..da22ca20b 100644 --- a/apps/web/src/app/settings/settings-form.utils.ts +++ b/apps/web/src/app/settings/settings-form.utils.ts @@ -114,6 +114,7 @@ export function createSettingsForm( embeddedMpvAutoReconnect: true, portalConnectivityGuard: true, coverSize: 'medium' as CoverSize, + showCoverTitles: true, ...(supportsEpg ? { preferUploadedEpgOverXtream: false, @@ -204,6 +205,7 @@ export function createSettingsFromFormValue( embeddedMpvAutoReconnect: value.embeddedMpvAutoReconnect ?? true, portalConnectivityGuard: value.portalConnectivityGuard !== false, coverSize: value.coverSize ?? 'medium', + showCoverTitles: value.showCoverTitles !== false, epgUrl, preferUploadedEpgOverXtream: value.preferUploadedEpgOverXtream ?? diff --git a/apps/web/src/app/settings/settings-general-section.component.html b/apps/web/src/app/settings/settings-general-section.component.html index f50b4e60e..452ed72a9 100644 --- a/apps/web/src/app/settings/settings-general-section.component.html +++ b/apps/web/src/app/settings/settings-general-section.component.html @@ -108,6 +108,25 @@ +
+
+

+ {{ 'SETTINGS.COVER_TITLES' | translate }} +

+

+ {{ 'SETTINGS.COVER_TITLES_DESCRIPTION' | translate }} +

+
+
+ +
+
+

{{ 'SETTINGS.STARTUP_BEHAVIOR' | translate }}

diff --git a/apps/web/src/app/settings/settings.component.form.spec.ts b/apps/web/src/app/settings/settings.component.form.spec.ts index 1e0eb7478..e7b05bdbf 100644 --- a/apps/web/src/app/settings/settings.component.form.spec.ts +++ b/apps/web/src/app/settings/settings.component.form.spec.ts @@ -81,6 +81,29 @@ describe('SettingsComponent form', () => { ); }); + it('stages the posters-only cover wall until Save', async () => { + const checkbox = (fixture.nativeElement as HTMLElement).querySelector( + '[data-test-id="cover-titles-toggle"] input' + ) as HTMLInputElement; + expect(checkbox).not.toBeNull(); + expect(checkbox.checked).toBe(true); + + checkbox.click(); + fixture.detectChanges(); + + expect(component.settingsForm.get('showCoverTitles')?.value).toBe( + false + ); + expect(settingsStore.updateSettings).not.toHaveBeenCalled(); + + await component.form.save(() => undefined); + + expect(settingsStore.updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ showCoverTitles: false }), + expect.anything() + ); + }); + it('hides the portal pause setting when the desktop bridge is unavailable', () => { fixture.destroy(); window.electron = undefined; diff --git a/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts b/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts index f1f851648..736f7cafe 100644 --- a/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts +++ b/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts @@ -115,6 +115,7 @@ export const DEFAULT_SETTINGS = { embeddedMpvAutoReconnect: true, portalConnectivityGuard: true, coverSize: 'medium', + showCoverTitles: true, dashboardRails: DEFAULT_DASHBOARD_RAILS, preferUploadedEpgOverXtream: false, epgViewMode: 'timeline', diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 89ce53221..7dcd6a825 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -385,6 +385,8 @@ "COVER_SIZE_SMALL": "صغير", "COVER_SIZE_MEDIUM": "متوسط", "COVER_SIZE_LARGE": "كبير", + "COVER_TITLES": "عرض العناوين أسفل الأغلفة", + "COVER_TITLES_DESCRIPTION": "عطّل هذا الخيار للحصول على جدار ملصقات فقط في شبكات الأفلام والمسلسلات: يظهر العنوان على الغلاف عند تمرير المؤشر فوقه أو التركيز عليه. تحتفظ القنوات المباشرة ونتائج البحث وأشرطة لوحة التحكم بعناوينها.", "EPG_VIEW_MODE": "عرض الدليل", "EPG_OFFSET_TITLE": "إزاحة وقت دليل البرامج", "EPG_OFFSET_HINT": "اضبط أوقات البرامج المعروضة دون تحديث الدليل. استخدم الدقائق من -720 إلى 720.", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 4cda264ba..05cbda747 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -385,6 +385,8 @@ "COVER_SIZE_SMALL": "صغير", "COVER_SIZE_MEDIUM": "متوسط", "COVER_SIZE_LARGE": "كبير", + "COVER_TITLES": "وري العناوين تحت الأغلفة", + "COVER_TITLES_DESCRIPTION": "طفي هاد الخيار باش تولي شبكات الأفلام والسلاسل حيط غير بالأغلفة: العنوان كيبان فوق الغلاف ملي تقرب بالماوس عليه ولا تفوكيسي عليه بالكيبورد. القنوات المباشرة، نتايج البحث وقضبان لوحة التحكم كيبقاو عناوينهم ظاهرين.", "EPG_VIEW_MODE": "عرض الدليل", "EPG_OFFSET_TITLE": "إزاحة وقت دليل البرامج", "EPG_OFFSET_HINT": "اضبط أوقات البرامج المعروضة دون تحديث الدليل. استخدم الدقائق من -720 إلى 720.", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index c858ca3b1..56c58b83f 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -385,6 +385,8 @@ "COVER_SIZE_SMALL": "Малы", "COVER_SIZE_MEDIUM": "Сярэдні", "COVER_SIZE_LARGE": "Вялікі", + "COVER_TITLES": "Паказаць назвы пад вокладкамі", + "COVER_TITLES_DESCRIPTION": "Выключыце, каб атрымаць сцяну толькі з постараў у сетках фільмаў і серыялаў: назва з'яўляецца на вокладцы пры навядзенні курсора або пераводзе фокусу на яе. Жывыя каналы, вынікі пошуку і стужкі панэлі заўсёды паказваюць свае назвы.", "EPG_VIEW_MODE": "Выгляд праграмы перадач", "EPG_OFFSET_TITLE": "Зрушэнне часу праграмы", "EPG_OFFSET_HINT": "Змяняйце час паказу праграм без абнаўлення даведніка. Выкарыстоўвайце хвіліны ад -720 да 720.", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index d7eb551c9..0503d6cac 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -385,6 +385,8 @@ "COVER_SIZE_SMALL": "Klein", "COVER_SIZE_MEDIUM": "Mittel", "COVER_SIZE_LARGE": "Groß", + "COVER_TITLES": "Titel unter Covern anzeigen", + "COVER_TITLES_DESCRIPTION": "Deaktivieren Sie diese Option für eine reine Poster-Wand in Film- und Serienrastern: Der Titel erscheint auf dem Cover, wenn Sie mit der Maus darauf zeigen oder es per Tastatur fokussieren. Live-Kanäle, Suchergebnisse und Dashboard-Reihen behalten ihre Beschriftungen.", "EPG_VIEW_MODE": "Programmführer-Ansicht", "EPG_OFFSET_TITLE": "EPG-Zeitverschiebung", "EPG_OFFSET_HINT": "Angezeigte Sendezeiten verschieben, ohne den Programmführer zu aktualisieren. Minuten von -720 bis 720 verwenden.", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 05e0aa73e..c20094823 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -385,6 +385,8 @@ "COVER_SIZE_SMALL": "Μικρό", "COVER_SIZE_MEDIUM": "Μεσαίο", "COVER_SIZE_LARGE": "Μεγάλο", + "COVER_TITLES": "Εμφάνιση τίτλων κάτω από τα εξώφυλλα", + "COVER_TITLES_DESCRIPTION": "Απενεργοποιήστε το για έναν τοίχο μόνο με αφίσες στα πλέγματα ταινιών και σειρών: ο τίτλος εμφανίζεται πάνω στο εξώφυλλο όταν περάσετε το ποντίκι από πάνω του ή το εστιάσετε. Τα ζωντανά κανάλια, τα αποτελέσματα αναζήτησης και οι σειρές του πίνακα διατηρούν τις ετικέτες τους.", "EPG_VIEW_MODE": "Προβολή οδηγού", "EPG_OFFSET_TITLE": "Μετατόπιση ώρας EPG", "EPG_OFFSET_HINT": "Μετατοπίστε τις εμφανιζόμενες ώρες προγραμμάτων χωρίς ανανέωση του οδηγού. Χρησιμοποιήστε λεπτά από -720 έως 720.", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 089c3d23d..a9fe5a77b 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -385,6 +385,8 @@ "COVER_SIZE_SMALL": "Small", "COVER_SIZE_MEDIUM": "Medium", "COVER_SIZE_LARGE": "Large", + "COVER_TITLES": "Show titles under covers", + "COVER_TITLES_DESCRIPTION": "Turn off for a posters-only wall in movie and series grids: the title appears on the cover when you point at it or focus it. Live channels, search results and dashboard rails keep their labels.", "EPG_VIEW_MODE": "Guide view", "EPG_OFFSET_TITLE": "EPG time offset", "EPG_OFFSET_HINT": "Shift displayed programme times without refreshing the guide. Use minutes, from -720 to 720.", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 0dd3485a5..1cced59af 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -385,6 +385,8 @@ "COVER_SIZE_SMALL": "Pequeño", "COVER_SIZE_MEDIUM": "Mediano", "COVER_SIZE_LARGE": "Grande", + "COVER_TITLES": "Mostrar títulos debajo de las portadas", + "COVER_TITLES_DESCRIPTION": "Desactívalo para lograr un muro de solo pósteres en las cuadrículas de películas y series: el título aparece sobre la portada al pasar el cursor por encima o al enfocarla. Los canales en vivo, los resultados de búsqueda y los carriles del panel mantienen sus etiquetas.", "EPG_VIEW_MODE": "Vista de la guía", "EPG_OFFSET_TITLE": "Desfase horario de la EPG", "EPG_OFFSET_HINT": "Desplaza las horas mostradas de los programas sin actualizar la guía. Usa minutos, de -720 a 720.", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index d6ff9d1f8..843ec3e85 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -385,6 +385,8 @@ "COVER_SIZE_SMALL": "Petite", "COVER_SIZE_MEDIUM": "Moyenne", "COVER_SIZE_LARGE": "Grande", + "COVER_TITLES": "Afficher les titres sous les affiches", + "COVER_TITLES_DESCRIPTION": "Désactivez cette option pour un mur d'affiches uniquement dans les grilles de films et de séries : le titre apparaît sur l'affiche lorsque vous la survolez ou lui donnez le focus. Les chaînes en direct, les résultats de recherche et les rangées du tableau de bord conservent leurs libellés.", "EPG_VIEW_MODE": "Vue du guide", "EPG_OFFSET_TITLE": "Décalage horaire de l’EPG", "EPG_OFFSET_HINT": "Décalez les horaires affichés des programmes sans actualiser le guide. Utilisez des minutes de -720 à 720.", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 0d31c50b5..2fb55d30f 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -385,6 +385,8 @@ "COVER_SIZE_SMALL": "Kicsi", "COVER_SIZE_MEDIUM": "Közepes", "COVER_SIZE_LARGE": "Nagy", + "COVER_TITLES": "Borítók alatti címek megjelenítése", + "COVER_TITLES_DESCRIPTION": "Kapcsolja ki, ha csak borítókból álló falat szeretne a film- és sorozatrácsokban: a cím a borítón jelenik meg, amikor rámutat az egérrel vagy arra fókuszál. Az élő csatornák, a keresési találatok és az irányítópulti sávok megtartják a feliratukat.", "EPG_VIEW_MODE": "Műsorújság nézete", "EPG_OFFSET_TITLE": "EPG-időeltolás", "EPG_OFFSET_HINT": "A műsorok megjelenített idejének eltolása a műsorújság frissítése nélkül. Percben adja meg, -720 és 720 között.", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 1b2ad6bb3..3bb7a3d33 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -385,6 +385,8 @@ "COVER_SIZE_SMALL": "Piccola", "COVER_SIZE_MEDIUM": "Media", "COVER_SIZE_LARGE": "Grande", + "COVER_TITLES": "Mostra i titoli sotto le copertine", + "COVER_TITLES_DESCRIPTION": "Disattiva per un muro di sole copertine nelle griglie di film e serie: il titolo appare sulla copertina quando ci passi sopra o la selezioni con la tastiera. I canali in diretta, i risultati di ricerca e le fasce della dashboard mantengono comunque le loro etichette.", "EPG_VIEW_MODE": "Vista guida", "EPG_OFFSET_TITLE": "Scostamento orario EPG", "EPG_OFFSET_HINT": "Sposta gli orari dei programmi visualizzati senza aggiornare la guida. Usa minuti da -720 a 720.", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index f45969134..f4c8ec8df 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -385,6 +385,8 @@ "COVER_SIZE_SMALL": "小", "COVER_SIZE_MEDIUM": "中", "COVER_SIZE_LARGE": "大", + "COVER_TITLES": "カバーの下にタイトルを表示", + "COVER_TITLES_DESCRIPTION": "オフにすると、映画・シリーズのグリッドがポスターのみの表示になります。マウスを合わせるか、フォーカスするとカバーにタイトルが表示されます。ライブチャンネル、検索結果、ダッシュボードのレールでは常にラベルが表示されます。", "EPG_VIEW_MODE": "番組表の表示", "EPG_OFFSET_TITLE": "EPG 時刻の補正", "EPG_OFFSET_HINT": "番組表を更新せずに表示時刻をずらします。-720 から 720 までの分単位で指定してください。", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index dbac6b39c..3429e5ae6 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -385,6 +385,8 @@ "COVER_SIZE_SMALL": "작게", "COVER_SIZE_MEDIUM": "중간", "COVER_SIZE_LARGE": "크게", + "COVER_TITLES": "커버 아래에 제목 표시", + "COVER_TITLES_DESCRIPTION": "끄면 영화 및 시리즈 그리드가 포스터만 표시되는 화면이 됩니다. 마우스로 가리키거나 키보드로 포커스하면 커버 위에 제목이 나타납니다. 라이브 채널, 검색 결과, 대시보드 레일은 항상 레이블을 유지합니다.", "EPG_VIEW_MODE": "가이드 보기", "EPG_OFFSET_TITLE": "EPG 시간 오프셋", "EPG_OFFSET_HINT": "가이드를 새로 고치지 않고 표시되는 프로그램 시간을 조정합니다. -720에서 720 사이의 분 단위로 입력하세요.", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 12738a0cd..d65ea1ab4 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -385,6 +385,8 @@ "COVER_SIZE_SMALL": "Klein", "COVER_SIZE_MEDIUM": "Gemiddeld", "COVER_SIZE_LARGE": "Groot", + "COVER_TITLES": "Titels onder hoezen tonen", + "COVER_TITLES_DESCRIPTION": "Schakel dit uit voor een muur met alleen posters in rasters met films en reeksen: de titel verschijnt op de hoes wanneer u de muis erover beweegt of hem focust. Livekanalen, zoekresultaten en dashboardrails behouden hun labels.", "EPG_VIEW_MODE": "Gidsweergave", "EPG_OFFSET_TITLE": "EPG-tijdverschuiving", "EPG_OFFSET_HINT": "Verschuif weergegeven programmatijden zonder de gids te vernieuwen. Gebruik minuten van -720 tot 720.", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index ff5859027..52275f216 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -385,6 +385,8 @@ "COVER_SIZE_SMALL": "Mały", "COVER_SIZE_MEDIUM": "Średni", "COVER_SIZE_LARGE": "Duży", + "COVER_TITLES": "Pokaż tytuły pod okładkami", + "COVER_TITLES_DESCRIPTION": "Wyłącz, aby uzyskać ścianę samych plakatów w siatkach filmów i seriali: tytuł pojawia się na okładce po najechaniu na nią kursorem lub przejściu do niej klawiaturą. Kanały na żywo, wyniki wyszukiwania i sekcje pulpitu nadal pokazują tytuły.", "EPG_VIEW_MODE": "Widok przewodnika", "EPG_OFFSET_TITLE": "Przesunięcie czasu EPG", "EPG_OFFSET_HINT": "Przesuń wyświetlane godziny programów bez odświeżania przewodnika. Użyj minut od -720 do 720.", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 1f6500029..364582bcb 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -385,6 +385,8 @@ "COVER_SIZE_SMALL": "Pequeno", "COVER_SIZE_MEDIUM": "Médio", "COVER_SIZE_LARGE": "Grande", + "COVER_TITLES": "Mostrar títulos sob as capas", + "COVER_TITLES_DESCRIPTION": "Desative para um mural apenas de pôsteres nas grades de filmes e séries: o título aparece sobre a capa ao passar o cursor ou focar nela. Canais ao vivo, resultados de pesquisa e os trilhos do dashboard mantêm os seus rótulos.", "EPG_VIEW_MODE": "Visualização do guia", "EPG_OFFSET_TITLE": "Deslocamento de horário do EPG", "EPG_OFFSET_HINT": "Desloque os horários dos programas exibidos sem atualizar o guia. Use minutos de -720 a 720.", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 690616e10..bdfe7dc3d 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -385,6 +385,8 @@ "COVER_SIZE_SMALL": "Маленький", "COVER_SIZE_MEDIUM": "Средний", "COVER_SIZE_LARGE": "Большой", + "COVER_TITLES": "Показывать названия под обложками", + "COVER_TITLES_DESCRIPTION": "Отключите, чтобы в сетках фильмов и сериалов отображались только обложки: название появляется на обложке при наведении курсора или получении фокуса. Live-каналы, результаты поиска и ленты панели по-прежнему показывают подписи.", "EPG_VIEW_MODE": "Вид программы передач", "EPG_OFFSET_TITLE": "Смещение времени телепрограммы", "EPG_OFFSET_HINT": "Смещайте время отображения программ без обновления телепрограммы. Укажите минуты от -720 до 720.", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 626df797c..5e883964d 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -385,6 +385,8 @@ "COVER_SIZE_SMALL": "Küçük", "COVER_SIZE_MEDIUM": "Orta", "COVER_SIZE_LARGE": "Büyük", + "COVER_TITLES": "Kapakların altında başlıkları göster", + "COVER_TITLES_DESCRIPTION": "Film ve dizi ızgaralarında yalnızca poster gösteren bir görünüm için kapatın: üzerine geldiğinizde veya odaklandığınızda başlık kapağın üzerinde görünür. Canlı kanallar, arama sonuçları ve pano bölümleri etiketlerini korur.", "EPG_VIEW_MODE": "Rehber görünümü", "EPG_OFFSET_TITLE": "EPG zaman kayması", "EPG_OFFSET_HINT": "Rehberi yenilemeden görüntülenen program saatlerini kaydırın. -720 ile 720 arasında dakika kullanın.", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index e10ba4496..5d3595771 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -385,6 +385,8 @@ "COVER_SIZE_SMALL": "小", "COVER_SIZE_MEDIUM": "中", "COVER_SIZE_LARGE": "大", + "COVER_TITLES": "在封面下方显示标题", + "COVER_TITLES_DESCRIPTION": "关闭后,电影和剧集网格将呈现纯海报墙:将指针悬停在封面上或将其聚焦时才会显示标题。直播频道、搜索结果和仪表盘栏目将始终显示标签。", "EPG_VIEW_MODE": "节目指南视图", "EPG_OFFSET_TITLE": "EPG 时间偏移", "EPG_OFFSET_HINT": "无需刷新节目指南即可调整显示的节目时间。请输入 -720 到 720 之间的分钟数。", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 308a2dba2..0afdb9eb0 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -385,6 +385,8 @@ "COVER_SIZE_SMALL": "小", "COVER_SIZE_MEDIUM": "中", "COVER_SIZE_LARGE": "大", + "COVER_TITLES": "在封面下方顯示標題", + "COVER_TITLES_DESCRIPTION": "停用後,電影和影集網格會變成純海報牆:滑鼠指向或聚焦封面時才會顯示標題。直播頻道、搜尋結果和儀表板欄位仍會保留標籤。", "EPG_VIEW_MODE": "節目表檢視方式", "EPG_OFFSET_TITLE": "EPG 時間偏移", "EPG_OFFSET_HINT": "無需重新整理節目表即可調整顯示的節目時間。請輸入 -720 到 720 之間的分鐘數。", diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 27b5f5e69..a07005111 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -234,6 +234,62 @@ minimum dimensions and flexible columns over fixed row widths. - Do not render placeholder and real logo at the same time - Keep logos contained with `object-fit: contain` +## Cover Grids + +Movie and series covers render in three surfaces: the catalog grid +(`app-grid-list`, `libs/portal/shared/ui/.../grid-list/`), the favorites / +recent card (`app-content-card`, same lib) and the dashboard rails. All of +them size from the `--cover-grid-min-width` / `--cover-rail-width` / +`--cover-gap` tokens that `Settings.coverSize` writes onto `` as +`data-cover-size` (`apps/web/src/_cover-size.scss`). + +### Posters-only wall + +`Settings.showCoverTitles` (Settings > General, default on, only an explicit +`false` opts out — coerced like `webPlayerSharedControls`) removes the title +row under VOD and series covers so the grid shows more rows per screen. + +- **Resolution.** `CoverTitlesService.postersOnly` (`libs/portal/shared/ui`) + is the single source: the opt-out AND a hover-capable pointer + (`(any-hover: hover)` media query, tracked live). On touch-only devices the + preference is ignored and titles stay under the covers, because a tap + already opens the item and there is no gesture left to peek at a hidden + name. +- **Scope.** Catalog grids (Xtream/Stalker VOD and series), unified + favorites/recent grids and the portal favorites tab. Exempt, regardless of + the setting: live channel grids (`type` `live`/`itv`/`radio` or the + `logo` variant — logos are too often missing to identify a channel), + search results and "recently added" rails (they answer by name; hosts + pass `[allowPostersOnly]="false"` to `app-content-card`; `app-grid-list` + and `app-unified-grid-tab` drop the wall themselves while their + `searchTerm` input is non-blank, i.e. an in-section search is filtering + the list), and + the dashboard rails (their meta rows do not fit an overlay). +- **Reveal.** The title is a `.cover-title-overlay` inside the poster + wrapper: bottom gradient scrim, two clamped lines, 150 ms ease-out + opacity, shown on `:hover` and `:focus-visible` of the card, none under + `prefers-reduced-motion`. It is `aria-hidden`; the card itself carries the + accessible name. +- **Pinned caption.** When the item has no cover to identify it — no + poster URL, or the image failed and the default poster / placeholder is + showing — the overlay is pinned open (`--pinned`). Both components track + failed URLs so the fallback branch re-renders instead of swapping `src` + in place. +- **Layout hints.** The grid's `contain-intrinsic-size` drops from 270 px to + 222 px (bare 2/3 poster) under `.grid-list--posters-only`, and the skeleton + hides its text lines so loading matches the cards it precedes. +- **Keyboard.** Both cards expose a `role="button"`, `tabindex="0"` surface + labelled by the title, activated by Enter and Space (Space prevents the + page scroll) and carrying a `:focus-visible` ring (`card-focus-ring` + mixin in `libs/ui/styles/_content-grid.scss`). On `app-content-card` that + surface is the inner `.content-card__activation` element, and the Remove + control (labelled by `removeTooltip`) is a SIBLING positioned over the + poster corner — an interactive control nested inside a `role="button"` + is an invalid accessibility structure. Its ring is drawn on the OUTER + `.content-card` via `:has(> .content-card__activation:focus-visible)`, + because the card's `overflow: hidden` would clip an outline on the inner + surface on every edge. Poster `alt` is the title, not a literal. + ## EPG Views The shared timeline and list still contain local dark surfaces, blue selection diff --git a/libs/portal/shared/ui/src/index.ts b/libs/portal/shared/ui/src/index.ts index f0133438e..1ad3105bb 100644 --- a/libs/portal/shared/ui/src/index.ts +++ b/libs/portal/shared/ui/src/index.ts @@ -1,6 +1,7 @@ export * from './lib/components/category-view/category-view.component'; export * from './lib/directives/infinite-scroll.directive'; export * from './lib/components/content-card/content-card.component'; +export * from './lib/cover-titles/cover-titles.service'; export * from './lib/components/content-rail-shell/content-rail-shell.component'; export * from './lib/components/favorites-layout/favorites-layout.component'; export * from './lib/components/global-favorites-list/global-favorites-list.component'; diff --git a/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.html b/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.html index a89a09cce..bd00ff363 100644 --- a/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.html +++ b/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.html @@ -1,52 +1,84 @@ -
-
- @if (showRemoveButton()) { - - } +
+ +
+
+ @if (posterUrl() && !artworkFailed()) { + + } @else if (showPlaceholder()) { +
+ {{ getPlaceholderIcon() }} +
+ } @else { + + } - @if (posterUrl()) { - - } @else if (showPlaceholder()) { -
- {{ getPlaceholderIcon() }} + @if (postersOnly()) { + + + } + + @if (type() && showTypeBadge()) { + + {{ type() }} + + } +
+ + @if (!postersOnly()) { +
+

{{ title() }}

+ @if (date()) { +

+ {{ + date() + | date: 'd MMM, HH:mm' : undefined : currentLocale() + }} +

+ }
- } @else { - - } - - @if (type() && showTypeBadge()) { - - {{ type() }} - }
-
-

{{ title() }}

- @if (date()) { -

- {{ date() | date: 'd MMM, HH:mm' : undefined : currentLocale() }} -

- } -
+ @if (showRemoveButton()) { + + }
diff --git a/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.scss b/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.scss index 6cc36dea1..00e628bd8 100644 --- a/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.scss +++ b/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.scss @@ -2,12 +2,30 @@ .content-card { @include grid.content-card; + position: relative; + + // Keyboard/pointer activation surface; the Remove button is a sibling + // absolutely positioned over the poster's corner. The focus ring is + // drawn on this outer card: `overflow: hidden` here would clip an + // outline on the inner surface on every edge. + .content-card__activation { + border-radius: inherit; + outline: none; + } + + &:has(> .content-card__activation:focus-visible) { + @include grid.focus-ring-declarations; + } + + .remove-button { + @include grid.remove-button; + } .thumbnail-wrapper { @include grid.thumbnail-wrapper; - .remove-button { - @include grid.remove-button; + .cover-title-overlay { + @include grid.cover-title-overlay; } .type-badge { @@ -19,3 +37,8 @@ @include grid.card-info; } } + +.content-card:hover .cover-title-overlay, +.content-card__activation:focus-visible .cover-title-overlay { + opacity: 1; +} diff --git a/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.spec.ts b/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.spec.ts new file mode 100644 index 000000000..7609b010c --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.spec.ts @@ -0,0 +1,196 @@ +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { TranslateService } from '@ngx-translate/core'; +import { of } from 'rxjs'; +import { SettingsStore } from '@iptvnator/services'; +import { ContentCardComponent } from './content-card.component'; + +describe('ContentCardComponent', () => { + let fixture: ComponentFixture; + let showCoverTitles: ReturnType>; + + beforeEach(async () => { + showCoverTitles = signal(false); + await TestBed.configureTestingModule({ + imports: [ContentCardComponent], + providers: [ + { provide: SettingsStore, useValue: { showCoverTitles } }, + { + provide: TranslateService, + useValue: { + onLangChange: of(), + currentLang: 'en', + defaultLang: 'en', + }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ContentCardComponent); + fixture.componentRef.setInput('title', 'Blade Runner'); + fixture.componentRef.setInput('posterUrl', 'blade-runner.jpg'); + fixture.componentRef.setInput('type', 'movie'); + }); + + const overlay = () => + fixture.debugElement.query(By.css('.cover-title-overlay')); + const info = () => fixture.debugElement.query(By.css('.card-info')); + + it('joins the posters-only wall by default: overlay instead of the info row', () => { + fixture.detectChanges(); + + expect(fixture.nativeElement.classList).toContain( + 'content-card--posters-only' + ); + expect(info()).toBeNull(); + expect(overlay().nativeElement.textContent.trim()).toBe( + 'Blade Runner' + ); + }); + + it('keeps the info row when the host opts the card out (search results)', () => { + fixture.componentRef.setInput('allowPostersOnly', false); + + fixture.detectChanges(); + + expect(info().nativeElement.textContent).toContain('Blade Runner'); + expect(overlay()).toBeNull(); + }); + + it.each(['live', 'radio'])( + 'keeps the info row for %s cards whose logos rarely identify them', + (type) => { + fixture.componentRef.setInput('type', type); + + fixture.detectChanges(); + + expect(info()).not.toBeNull(); + expect(overlay()).toBeNull(); + } + ); + + it('pins the overlay open for a missing or broken cover', () => { + fixture.componentRef.setInput('posterUrl', undefined); + fixture.componentRef.setInput('showPlaceholder', false); + fixture.detectChanges(); + expect(overlay().nativeElement.classList).toContain( + 'cover-title-overlay--pinned' + ); + + fixture.componentRef.setInput('posterUrl', 'broken.jpg'); + fixture.detectChanges(); + expect(overlay().nativeElement.classList).not.toContain( + 'cover-title-overlay--pinned' + ); + + fixture.debugElement + .query(By.css('.poster')) + .nativeElement.dispatchEvent(new Event('error')); + fixture.detectChanges(); + + expect( + fixture.debugElement + .query(By.css('.poster')) + .nativeElement.getAttribute('src') + ).toContain('default-poster.png'); + expect(overlay().nativeElement.classList).toContain( + 'cover-title-overlay--pinned' + ); + }); + + it('gives a new poster URL a fresh chance after a failed one', () => { + fixture.componentRef.setInput('posterUrl', 'broken.jpg'); + fixture.detectChanges(); + fixture.debugElement + .query(By.css('.poster')) + .nativeElement.dispatchEvent(new Event('error')); + fixture.detectChanges(); + + fixture.componentRef.setInput('posterUrl', 'fixed.jpg'); + fixture.detectChanges(); + + expect( + fixture.debugElement + .query(By.css('.poster')) + .nativeElement.getAttribute('src') + ).toBe('fixed.jpg'); + }); + + it('renders the info row while titles are enabled', () => { + showCoverTitles.set(true); + + fixture.detectChanges(); + + expect(fixture.nativeElement.classList).not.toContain( + 'content-card--posters-only' + ); + expect(info().nativeElement.textContent).toContain('Blade Runner'); + expect(overlay()).toBeNull(); + }); + + it('is a keyboard-activatable button named after the item', () => { + fixture.detectChanges(); + const clicked = jest.fn(); + fixture.componentInstance.cardClick.subscribe(clicked); + const card = fixture.debugElement.query( + By.css('.content-card__activation') + ); + + expect(card.nativeElement.getAttribute('role')).toBe('button'); + expect(card.nativeElement.getAttribute('tabindex')).toBe('0'); + expect(card.nativeElement.getAttribute('aria-label')).toBe( + 'Blade Runner' + ); + + const cardElement = card.nativeElement as HTMLElement; + cardElement.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }) + ); + const space = new KeyboardEvent('keydown', { + key: ' ', + bubbles: true, + cancelable: true, + }); + cardElement.dispatchEvent(space); + + expect(clicked).toHaveBeenCalledTimes(2); + expect(space.defaultPrevented).toBe(true); + }); + + it('keeps the Remove control outside the card button so its keys never open the item', () => { + fixture.componentRef.setInput('showRemoveButton', true); + fixture.componentRef.setInput('removeTooltip', 'Remove from favorites'); + fixture.detectChanges(); + const clicked = jest.fn(); + const removed = jest.fn(); + fixture.componentInstance.cardClick.subscribe(clicked); + fixture.componentInstance.remove.subscribe(removed); + const removeButton = fixture.debugElement.query( + By.css('.remove-button') + ).nativeElement as HTMLElement; + + // No interactive control nested inside a role="button". + expect(removeButton.closest('[role="button"]')).toBeNull(); + expect(removeButton.getAttribute('aria-label')).toBe( + 'Remove from favorites' + ); + + removeButton.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }) + ); + const space = new KeyboardEvent('keydown', { + key: ' ', + bubbles: true, + cancelable: true, + }); + removeButton.dispatchEvent(space); + removeButton.click(); + + // The button's own activation removes the item; the card must + // neither open it nor swallow the Space the button relies on. + expect(clicked).not.toHaveBeenCalled(); + expect(space.defaultPrevented).toBe(false); + expect(removed).toHaveBeenCalledTimes(1); + }); +}); diff --git a/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.ts b/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.ts index 6c8ae46de..3e8316ae9 100644 --- a/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.ts +++ b/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.ts @@ -5,6 +5,7 @@ import { computed, inject, input, + linkedSignal, output, } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; @@ -14,6 +15,10 @@ import { MatTooltip } from '@angular/material/tooltip'; import { normalizeDateLocale } from '@iptvnator/pipes'; import { TranslateService } from '@ngx-translate/core'; import { startWith } from 'rxjs'; +import { CoverTitlesService } from '../../cover-titles/cover-titles.service'; + +/** Channel-like cards always keep their label: logos rarely identify them. */ +const LABELLED_CONTENT_TYPES: ReadonlySet = new Set(['live', 'radio']); @Component({ selector: 'app-content-card', @@ -21,10 +26,12 @@ import { startWith } from 'rxjs'; imports: [DatePipe, MatIcon, MatIconButton, MatTooltip], templateUrl: './content-card.component.html', styleUrl: './content-card.component.scss', + host: { '[class.content-card--posters-only]': 'postersOnly()' }, changeDetection: ChangeDetectionStrategy.OnPush, }) export class ContentCardComponent { private readonly translate = inject(TranslateService); + private readonly coverTitles = inject(CoverTitlesService); private readonly languageTick = toSignal( this.translate.onLangChange.pipe(startWith(null)), { initialValue: null } @@ -54,11 +61,33 @@ export class ContentCardComponent { /** Whether to render the type badge (live/movie/series) on the poster */ readonly showTypeBadge = input(true); + /** + * Whether this card may join the posters-only wall + * (`Settings.showCoverTitles === false`). Hosts whose grids answer by + * name — search results, "recently added" rails — pass false so their + * labels stay put regardless of the preference. + */ + readonly allowPostersOnly = input(true); + /** Emitted when the card is clicked */ readonly cardClick = output(); /** Emitted when the remove button is clicked */ readonly remove = output(); + /** A failed poster URL falls back per card; a new URL gets a fresh try. */ + protected readonly artworkFailed = linkedSignal({ + source: this.posterUrl, + computation: () => false, + }); + protected readonly artworkMissing = computed( + () => !this.posterUrl() || this.artworkFailed() + ); + protected readonly postersOnly = computed( + () => + this.allowPostersOnly() && + this.coverTitles.postersOnly() && + !LABELLED_CONTENT_TYPES.has(this.type() ?? '') + ); readonly currentLocale = computed(() => { this.languageTick(); return normalizeDateLocale( @@ -84,13 +113,22 @@ export class ContentCardComponent { this.cardClick.emit(); } + /** + * Enter/Space activate the card like a click; Space also prevents the + * page scroll. The Remove control is a sibling of the activation + * surface, never a descendant, so its keys cannot reach this handler. + */ + onCardKey(event: Event): void { + event.preventDefault(); + this.cardClick.emit(); + } + onRemoveClick(event: Event): void { event.stopPropagation(); this.remove.emit(); } - onImageError(event: Event): void { - (event.target as HTMLImageElement).src = - './assets/images/default-poster.png'; + onImageError(): void { + this.artworkFailed.set(true); } } diff --git a/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.scss b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.scss index ab0852775..3a8ef2f6d 100644 --- a/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.scss +++ b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.scss @@ -1,4 +1,5 @@ @use '../../styles/skeleton'; +@use '../../styles/content-grid' as grid; // ─── Card base ─────────────────────────────────────────────────────────────── mat-card { @@ -15,6 +16,10 @@ mat-card:hover { box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12); } +mat-card { + @include grid.card-focus-ring; +} + // ─── Grid layout ───────────────────────────────────────────────────────────── .grid-list__grid { display: grid; @@ -90,6 +95,15 @@ mat-card:hover { } } + .cover-title-overlay { + @include grid.cover-title-overlay; + } + + &:hover .cover-title-overlay, + &:focus-visible .cover-title-overlay { + opacity: 1; + } + .title { margin-top: 8px; padding: 0 2px; @@ -211,6 +225,19 @@ mat-card:hover { contain-intrinsic-size: auto 270px; } +// ─── Posters-only wall ─────────────────────────────────────────────────────── +// No title row: the hint shrinks to the bare 2/3 poster, and the skeleton +// drops its text lines so the loading state matches the cards it precedes. +:host.grid-list--posters-only { + .grid-list__grid mat-card { + contain-intrinsic-size: auto 222px; + } + + .grid-skeleton-title { + display: none; + } +} + .grid-list__tail { display: flex; align-items: center; diff --git a/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.spec.ts b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.spec.ts index 441991135..c24e8a2be 100644 --- a/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.spec.ts @@ -311,3 +311,218 @@ describe('GridListComponent with strip country prefix enabled', () => { expect(renderedTitle()).toBe('US | Some Movie'); }); }); + +describe('GridListComponent posters-only wall', () => { + let fixture: ComponentFixture; + let showCoverTitles: ReturnType>; + + beforeEach(async () => { + showCoverTitles = signal(false); + await TestBed.configureTestingModule({ + imports: [GridListComponent], + providers: [ + { + provide: SettingsStore, + useValue: { showCoverTitles }, + }, + ], + }) + .overrideComponent(GridListComponent, { + remove: { imports: [TranslatePipe] }, + add: { + imports: [ + MockPipe( + TranslatePipe, + (value: string | null | undefined) => value ?? '' + ), + ], + }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(GridListComponent); + }); + + const overlay = () => + fixture.debugElement.query(By.css('.cover-title-overlay')); + + it('replaces the title row with a hover overlay on VOD covers', () => { + fixture.componentRef.setInput('items', [ + { title: 'Blade Runner', poster_url: 'blade-runner.jpg' }, + ]); + fixture.componentRef.setInput('type', 'vod'); + + fixture.detectChanges(); + + expect(fixture.nativeElement.classList).toContain( + 'grid-list--posters-only' + ); + expect(fixture.debugElement.query(By.css('.title'))).toBeNull(); + expect(overlay().nativeElement.textContent.trim()).toBe( + 'Blade Runner' + ); + expect(overlay().nativeElement.classList).not.toContain( + 'cover-title-overlay--pinned' + ); + }); + + it('pins the overlay open when the item has no cover to identify it', () => { + fixture.componentRef.setInput('items', [{ title: 'No Poster Film' }]); + fixture.componentRef.setInput('type', 'vod'); + + fixture.detectChanges(); + + expect(overlay().nativeElement.classList).toContain( + 'cover-title-overlay--pinned' + ); + }); + + it('pins the overlay once the cover fails to load', () => { + fixture.componentRef.setInput('items', [ + { title: 'Broken Poster Film', poster_url: 'broken.jpg' }, + ]); + fixture.componentRef.setInput('type', 'vod'); + fixture.detectChanges(); + expect(overlay().nativeElement.classList).not.toContain( + 'cover-title-overlay--pinned' + ); + + fixture.debugElement + .query(By.css('.stream-icon')) + .nativeElement.dispatchEvent(new Event('error')); + fixture.detectChanges(); + + // The template re-renders the default poster, and the overlay + // stays open because that poster identifies nothing. + expect( + fixture.debugElement + .query(By.css('.stream-icon')) + .nativeElement.getAttribute('src') + ).toContain('default-poster.png'); + expect(overlay().nativeElement.classList).toContain( + 'cover-title-overlay--pinned' + ); + }); + + it('keeps the title row on live channel grids regardless of the setting', () => { + fixture.componentRef.setInput('items', [ + { name: 'CNN', stream_icon: 'cnn.png' }, + ]); + fixture.componentRef.setInput('type', 'live'); + fixture.componentRef.setInput('variant', 'logo'); + + fixture.detectChanges(); + + expect(fixture.nativeElement.classList).not.toContain( + 'grid-list--posters-only' + ); + expect( + fixture.debugElement.query(By.css('.title')).nativeElement + .textContent + ).toContain('CNN'); + expect(overlay()).toBeNull(); + }); + + it('keeps the title row while an in-section search filters the grid', () => { + fixture.componentRef.setInput('items', [ + { title: 'Blade Runner', poster_url: 'blade-runner.jpg' }, + ]); + fixture.componentRef.setInput('type', 'vod'); + fixture.componentRef.setInput('searchTerm', 'blade'); + + fixture.detectChanges(); + + expect(fixture.nativeElement.classList).not.toContain( + 'grid-list--posters-only' + ); + expect( + fixture.debugElement.query(By.css('.title')).nativeElement + .textContent + ).toContain('Blade Runner'); + expect(overlay()).toBeNull(); + + fixture.componentRef.setInput('searchTerm', ' '); + fixture.detectChanges(); + + expect(fixture.debugElement.query(By.css('.title'))).toBeNull(); + expect(overlay()).not.toBeNull(); + }); + + it('restores the title row as soon as the setting is switched back on', () => { + fixture.componentRef.setInput('items', [ + { title: 'Blade Runner', poster_url: 'blade-runner.jpg' }, + ]); + fixture.componentRef.setInput('type', 'vod'); + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('.title'))).toBeNull(); + + showCoverTitles.set(true); + fixture.detectChanges(); + + expect( + fixture.debugElement.query(By.css('.title')).nativeElement + .textContent + ).toContain('Blade Runner'); + expect(overlay()).toBeNull(); + }); +}); + +describe('GridListComponent keyboard access', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [GridListComponent], + providers: [{ provide: SettingsStore, useValue: {} }], + }) + .overrideComponent(GridListComponent, { + remove: { imports: [TranslatePipe] }, + add: { + imports: [ + MockPipe( + TranslatePipe, + (value: string | null | undefined) => value ?? '' + ), + ], + }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(GridListComponent); + fixture.componentRef.setInput('items', [ + { title: 'Blade Runner', poster_url: 'blade-runner.jpg' }, + ]); + fixture.componentRef.setInput('type', 'vod'); + fixture.detectChanges(); + }); + + it('exposes each card as a focusable button named after the item', () => { + const card = fixture.debugElement.query(By.css('mat-card')) + .nativeElement as HTMLElement; + + expect(card.getAttribute('role')).toBe('button'); + expect(card.getAttribute('tabindex')).toBe('0'); + expect(card.getAttribute('aria-label')).toBe('Blade Runner'); + expect( + fixture.debugElement + .query(By.css('.stream-icon')) + .nativeElement.getAttribute('alt') + ).toBe('Blade Runner'); + }); + + it('activates a card with Enter and Space without scrolling the grid', () => { + const clicked = jest.fn(); + fixture.componentInstance.itemClicked.subscribe(clicked); + const card = fixture.debugElement.query(By.css('mat-card')); + + card.triggerEventHandler('keydown.enter', new KeyboardEvent('keydown')); + const space = new KeyboardEvent('keydown', { + key: ' ', + cancelable: true, + }); + card.triggerEventHandler('keydown.space', space); + + expect(clicked).toHaveBeenCalledTimes(2); + expect(space.defaultPrevented).toBe(true); + }); +}); diff --git a/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.ts b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.ts index ab32cc20a..dcd42b66b 100644 --- a/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.ts +++ b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.ts @@ -23,6 +23,7 @@ import { ProgressCapsuleComponent, WatchedBadgeComponent, } from '@iptvnator/ui/components'; +import { CoverTitlesService } from '../../cover-titles/cover-titles.service'; import { PlaylistErrorViewComponent } from '../playlist-error-view/playlist-error-view.component'; interface GridListItem { @@ -107,19 +108,26 @@ function normalizeArtworkUrl(value: string | undefined): string | undefined { } @else { @for (item of items(); track $index) { @let i = $any(item); + @let title = channelTitle(i); @let poster = resolvePoster(i); + @let artworkMissing = !poster || hasArtworkFailed(poster);
- @if (poster && !hasArtworkFailed(poster)) { + @if (!artworkMissing) { logo } @else if ( shouldRenderArtworkPlaceholder(poster) @@ -137,9 +145,25 @@ function normalizeArtworkUrl(value: string | undefined): string | undefined { class="stream-icon" src="./assets/images/default-poster.png" loading="lazy" - alt="logo" + [alt]="title" /> } + @if (postersOnly()) { + + + } @if (i.progress && i.progress > 0) { } @@ -182,11 +206,11 @@ function normalizeArtworkUrl(value: string | undefined): string | undefined { star{{ rating }}
} - -
- {{ channelTitle(i) }} -
-
+ @if (!postersOnly()) { + +
{{ title }}
+
+ }
} @empty {
@@ -250,11 +274,13 @@ function normalizeArtworkUrl(value: string | undefined): string | undefined { ProgressCapsuleComponent, WatchedBadgeComponent, ], + host: { '[class.grid-list--posters-only]': 'postersOnly()' }, changeDetection: ChangeDetectionStrategy.OnPush, }) export class GridListComponent { private readonly failedArtworkUrls = signal>(new Set()); private readonly settingsStore = inject(SettingsStore); + private readonly coverTitles = inject(CoverTitlesService); readonly items = input([]); readonly isLoading = input(false); @@ -282,6 +308,20 @@ export class GridListComponent { private readonly isLiveGrid = computed(() => ['live', 'itv', 'radio'].includes(this.type()) ); + /** + * Posters-only wall (`Settings.showCoverTitles === false`) applies to + * VOD/series covers only: channel logos are too often missing or + * generic to identify a channel without its name. A grid filtered by + * an in-section search keeps its titles too — those results are + * identified by the name the user just typed. + */ + protected readonly postersOnly = computed( + () => + this.coverTitles.postersOnly() && + !this.isLiveGrid() && + this.variant() !== 'logo' && + !this.hasActiveSearch() + ); protected readonly catchupDays = getXtreamCatchupDays; /** Catch-up badge is live-grid only; VOD/series rows never carry it. */ protected showCatchupBadge(item: GridListItem): boolean { @@ -329,23 +369,28 @@ export class GridListComponent { } } + /** Space activates like a click but must not scroll the grid. */ + protected onSpaceKey(event: Event, item: GridListItem): void { + event.preventDefault(); + this.itemClicked.emit(item); + } + + /** + * A failed poster is remembered per URL so the template re-renders the + * fallback branch (placeholder or default poster) — and the posters-only + * overlay can pin the title, since a default poster identifies nothing. + */ protected onImageError(event: Event, poster: string): void { - if (this.usesArtworkPlaceholder()) { - this.failedArtworkUrls.update((failedUrls) => { - const nextFailedUrls = new Set(failedUrls); - nextFailedUrls.add(poster); + this.failedArtworkUrls.update((failedUrls) => { + const nextFailedUrls = new Set(failedUrls); + nextFailedUrls.add(poster); - return nextFailedUrls; - }); - (event.target as HTMLImageElement | null)?.style.setProperty( - 'display', - 'none' - ); - return; - } - - (event.target as HTMLImageElement).src = - './assets/images/default-poster.png'; + return nextFailedUrls; + }); + (event.target as HTMLImageElement | null)?.style.setProperty( + 'display', + 'none' + ); } private usesArtworkPlaceholder(): boolean { diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-grid-tab.component.html b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-grid-tab.component.html index 73b9928ef..445e8f5de 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-grid-tab.component.html +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-grid-tab.component.html @@ -20,6 +20,7 @@
@for (item of filteredItems(); track trackByUid($index, item)) { { + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [UnifiedGridTabComponent], + providers: [ + { + provide: SettingsStore, + useValue: { showCoverTitles: signal(false) }, + }, + { + provide: TranslateService, + useValue: { + onLangChange: of(), + currentLang: 'en', + defaultLang: 'en', + }, + }, + ], + }) + .overrideComponent(UnifiedGridTabComponent, { + remove: { imports: [TranslatePipe] }, + add: { + imports: [ + MockPipe( + TranslatePipe, + (value: string | null | undefined) => value ?? '' + ), + ], + }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(UnifiedGridTabComponent); + fixture.componentRef.setInput('items', ITEMS); + }); + + const cardInfos = () => fixture.debugElement.queryAll(By.css('.card-info')); + const overlays = () => + fixture.debugElement.queryAll(By.css('.cover-title-overlay')); + + it('hides titles behind the overlay while nothing is being searched', () => { + fixture.detectChanges(); + + expect(cardInfos()).toHaveLength(0); + expect(overlays()).toHaveLength(2); + }); + + it('keeps titles visible while a search term filters the collection', () => { + fixture.componentRef.setInput('searchTerm', 'blade'); + fixture.detectChanges(); + + expect(cardInfos()).toHaveLength(1); + expect(cardInfos()[0].nativeElement.textContent).toContain( + 'Blade Runner' + ); + expect(overlays()).toHaveLength(0); + + fixture.componentRef.setInput('searchTerm', ' '); + fixture.detectChanges(); + + expect(cardInfos()).toHaveLength(0); + expect(overlays()).toHaveLength(2); + }); +}); diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-grid-tab.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-grid-tab.component.ts index 7a73b229e..ff88648f5 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-grid-tab.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-grid-tab.component.ts @@ -29,8 +29,19 @@ export class UnifiedGridTabComponent { readonly removeItem = output(); readonly itemSelected = output(); + private readonly normalizedSearchTerm = computed(() => + this.searchTerm().trim().toLowerCase() + ); + /** + * Search results are identified by the name the user typed, so a + * filtered grid keeps its titles even under the posters-only wall. + */ + readonly hasActiveSearch = computed( + () => this.normalizedSearchTerm().length > 0 + ); + readonly filteredItems = computed(() => { - const term = this.searchTerm().trim().toLowerCase(); + const term = this.normalizedSearchTerm(); const all = this.items(); return term ? all.filter((i) => i.name.toLowerCase().includes(term)) diff --git a/libs/portal/shared/ui/src/lib/cover-titles/cover-titles.service.spec.ts b/libs/portal/shared/ui/src/lib/cover-titles/cover-titles.service.spec.ts new file mode 100644 index 000000000..73da1a4b3 --- /dev/null +++ b/libs/portal/shared/ui/src/lib/cover-titles/cover-titles.service.spec.ts @@ -0,0 +1,98 @@ +import { Injector, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { SettingsStore } from '@iptvnator/services'; +import { CoverTitlesService } from './cover-titles.service'; + +type ChangeListener = (event: { matches: boolean }) => void; + +describe('CoverTitlesService', () => { + const originalMatchMedia = window.matchMedia; + let showCoverTitles: ReturnType>; + let hoverMatches: boolean; + let listeners: ChangeListener[]; + let removeListener: jest.Mock; + + beforeEach(() => { + showCoverTitles = signal(true); + hoverMatches = true; + listeners = []; + removeListener = jest.fn(); + window.matchMedia = jest.fn((query: string) => ({ + matches: query === '(any-hover: hover)' ? hoverMatches : false, + addEventListener: (_type: string, listener: ChangeListener) => { + listeners.push(listener); + }, + removeEventListener: removeListener, + })) as unknown as typeof window.matchMedia; + + TestBed.configureTestingModule({ + providers: [ + CoverTitlesService, + { provide: SettingsStore, useValue: { showCoverTitles } }, + ], + }); + }); + + afterEach(() => { + window.matchMedia = originalMatchMedia; + }); + + it('keeps titles while the setting is on or absent', () => { + const service = TestBed.inject(CoverTitlesService); + expect(service.postersOnly()).toBe(false); + + showCoverTitles.set(undefined); + expect(service.postersOnly()).toBe(false); + }); + + it('switches to the posters-only wall only on an explicit opt-out', () => { + const service = TestBed.inject(CoverTitlesService); + + showCoverTitles.set(false); + + expect(service.postersOnly()).toBe(true); + }); + + it('ignores the opt-out on touch-only devices where nothing can hover', () => { + hoverMatches = false; + const service = TestBed.inject(CoverTitlesService); + showCoverTitles.set(false); + + expect(service.postersOnly()).toBe(false); + }); + + it('reads any-hover, not hover, so a touch-first device with a mouse keeps the wall', () => { + TestBed.inject(CoverTitlesService); + + expect(window.matchMedia).toHaveBeenCalledWith('(any-hover: hover)'); + }); + + it('follows the hover capability when a pointer is attached or removed', () => { + hoverMatches = false; + const service = TestBed.inject(CoverTitlesService); + showCoverTitles.set(false); + expect(service.postersOnly()).toBe(false); + + listeners.forEach((listener) => listener({ matches: true })); + expect(service.postersOnly()).toBe(true); + + listeners.forEach((listener) => listener({ matches: false })); + expect(service.postersOnly()).toBe(false); + }); + + it('unsubscribes from the media query when its injector is destroyed', () => { + const injector = Injector.create({ + providers: [ + { provide: CoverTitlesService }, + { provide: SettingsStore, useValue: { showCoverTitles } }, + ], + parent: TestBed.inject(Injector), + }); + injector.get(CoverTitlesService); + expect(listeners).toHaveLength(1); + + (injector as unknown as { destroy(): void }).destroy(); + + expect(removeListener).toHaveBeenCalledWith('change', listeners[0]); + }); +}); diff --git a/libs/portal/shared/ui/src/lib/cover-titles/cover-titles.service.ts b/libs/portal/shared/ui/src/lib/cover-titles/cover-titles.service.ts new file mode 100644 index 000000000..797b7d7c5 --- /dev/null +++ b/libs/portal/shared/ui/src/lib/cover-titles/cover-titles.service.ts @@ -0,0 +1,61 @@ +import { + computed, + DestroyRef, + inject, + Injectable, + signal, + Signal, +} from '@angular/core'; +import { SettingsStore } from '@iptvnator/services'; + +const HOVER_CAPABLE_QUERY = '(any-hover: hover)'; + +/** + * Resolves whether cover grids should render as a posters-only wall. + * + * The wall hides the title row under every cover and reveals the title as a + * hover/focus overlay instead. That overlay needs a pointer that can hover, + * so on touch-only devices the preference is ignored and the titles stay + * under the covers — a tap already opens the item, leaving no gesture to + * peek at a hidden name. `any-hover` rather than `hover`: the latter + * describes only the PRIMARY pointer, so a touch-first tablet with a mouse + * or hover-capable stylus attached would wrongly lose the wall. + * + * Consumers still apply their own exemptions (live grids, search results) + * on top of this signal; it only answers "does the user want the wall AND + * can this device show it". + */ +@Injectable({ providedIn: 'root' }) +export class CoverTitlesService { + private readonly settingsStore = inject(SettingsStore); + private readonly hoverCapable = createHoverCapabilitySignal( + inject(DestroyRef) + ); + + /** True when covers render without a title row. */ + readonly postersOnly = computed( + () => + this.settingsStore.showCoverTitles?.() === false && + this.hoverCapable() + ); +} + +function createHoverCapabilitySignal(destroyRef: DestroyRef): Signal { + // No `matchMedia` (SSR, some test hosts) means we cannot prove a hover + // pointer exists; default to true so desktop keeps the feature in those + // environments and the touch fallback only triggers on real evidence. + if (typeof window === 'undefined' || !('matchMedia' in window)) { + return signal(true).asReadonly(); + } + + const query = window.matchMedia(HOVER_CAPABLE_QUERY); + const hoverCapable = signal(query.matches); + const onChange = (event: MediaQueryListEvent): void => { + hoverCapable.set(event.matches); + }; + + query.addEventListener('change', onChange); + destroyRef.onDestroy(() => query.removeEventListener('change', onChange)); + + return hoverCapable.asReadonly(); +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.html b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.html index 426ac5ad1..51fcbfa97 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.html @@ -56,6 +56,7 @@
@for (item of searchResults(); track item.id) { @for (item of recentlyAddedVod(); track item.xtream_id) { @for (item of recentlyAddedSeries(); track item.xtream_id) { @for (item of recentlyAddedLive(); track item.xtream_id) { @for (item of group.items; track item.id) { @for (item of xtreamStore.searchResults(); track item.id) { @for (item of visibleInPortalResults(); track item.id) { { + it('resolves every default-on flag, treating only explicit false as an opt-out', () => { + expect( + resolveDefaultOnSettings({ + webPlayerSharedControls: false, + portalConnectivityGuard: 'false' as unknown as boolean, + }) + ).toEqual({ + webPlayerSharedControls: false, + portalConnectivityGuard: true, + embeddedMpvAutoReconnect: true, + showCoverTitles: true, + }); + }); + + it('coerces only the flags an update actually carries', () => { + expect( + coerceProvidedDefaultOnSettings({ + showCoverTitles: false, + embeddedMpvAutoReconnect: undefined, + language: undefined, + }) + ).toEqual({ showCoverTitles: false }); + expect(coerceProvidedDefaultOnSettings({})).toEqual({}); + }); +}); diff --git a/libs/services/src/lib/settings-opt-out.util.ts b/libs/services/src/lib/settings-opt-out.util.ts new file mode 100644 index 000000000..bb159e011 --- /dev/null +++ b/libs/services/src/lib/settings-opt-out.util.ts @@ -0,0 +1,47 @@ +import { Settings } from '@iptvnator/shared/interfaces'; + +/** + * Boolean settings that default ON and are stored as an explicit opt-out. + * + * Absent in settings stored before the default flip means "never chose" — + * those users get the default; only an explicit `false` opts out, so junk + * such as the string `'false'` falls back to the default too. + */ +export const DEFAULT_ON_SETTINGS = [ + 'webPlayerSharedControls', + 'portalConnectivityGuard', + 'embeddedMpvAutoReconnect', + 'showCoverTitles', +] as const satisfies readonly (keyof Settings)[]; + +export type DefaultOnSetting = (typeof DEFAULT_ON_SETTINGS)[number]; + +/** Every default-on flag resolved: `true` unless stored as exactly `false`. */ +export function resolveDefaultOnSettings( + stored: Partial +): Record { + // `reduce` rather than `Object.fromEntries`: the web build lib target + // predates ES2019. + return DEFAULT_ON_SETTINGS.reduce( + (resolved, key) => { + resolved[key] = stored[key] !== false; + return resolved; + }, + {} as Record + ); +} + +/** Only the default-on flags the caller supplied, coerced the same way. */ +export function coerceProvidedDefaultOnSettings( + settings: Partial +): Partial> { + return DEFAULT_ON_SETTINGS.reduce( + (coerced, key) => { + if (settings[key] !== undefined) { + coerced[key] = settings[key] !== false; + } + return coerced; + }, + {} as Partial> + ); +} diff --git a/libs/services/src/lib/settings-store.service.spec.ts b/libs/services/src/lib/settings-store.service.spec.ts index b2ff5c20e..029d2aaca 100644 --- a/libs/services/src/lib/settings-store.service.spec.ts +++ b/libs/services/src/lib/settings-store.service.spec.ts @@ -270,6 +270,36 @@ describe('SettingsStore dashboard rail settings', () => { expect(store.getSettings().webPlayerSharedControls).toBe(false); }); + it('defaults cover titles to shown when the stored field is missing', async () => { + storedSettings = {}; + const store = injector.get(SettingsStore); + + await store.loadSettings(); + + expect(store.getSettings().showCoverTitles).toBe(true); + }); + + it('restores a persisted posters-only opt-out and persists it back', async () => { + storedSettings = { showCoverTitles: false }; + const store = injector.get(SettingsStore); + + await store.loadSettings(); + expect(store.getSettings().showCoverTitles).toBe(false); + + await store.updateSettings({ showCoverTitles: true }); + expect(store.showCoverTitles?.()).toBe(true); + expect(storage.set).toHaveBeenLastCalledWith( + STORE_KEY.Settings, + expect.objectContaining({ showCoverTitles: true }) + ); + + await store.updateSettings({ + showCoverTitles: 'false' as unknown as boolean, + }); + // Junk never opts out: only an explicit boolean false does. + expect(store.showCoverTitles?.()).toBe(true); + }); + it('defaults embedded MPV auto-reconnect to true when the stored field is missing', async () => { storedSettings = {}; const store = injector.get(SettingsStore); diff --git a/libs/services/src/lib/settings-store.service.ts b/libs/services/src/lib/settings-store.service.ts index d230cfc91..bc7ba2708 100644 --- a/libs/services/src/lib/settings-store.service.ts +++ b/libs/services/src/lib/settings-store.service.ts @@ -2,6 +2,10 @@ import { EpgSourceSettingsService, epgSourceUrlsChanged, } from './epg-source-settings.service'; +import { + coerceProvidedDefaultOnSettings, + resolveDefaultOnSettings, +} from './settings-opt-out.util'; import { computed, inject } from '@angular/core'; import { patchState, @@ -64,6 +68,7 @@ const DEFAULT_SETTINGS: Settings = { embeddedMpvAutoReconnect: true, portalConnectivityGuard: true, coverSize: 'medium', + showCoverTitles: true, epgViewMode: 'timeline', epgOffsetMinutes: 0, dashboardRails: DEFAULT_DASHBOARD_RAILS_SETTINGS, @@ -169,19 +174,7 @@ export const SettingsStore = signalStore( epgOffsetMinutes: normalizeEpgOffsetMinutes( storedSettings.epgOffsetMinutes ), - - // Absent in settings stored before the default - // flip means "never chose" — those users get the - // new default; only an explicit false opts out. - webPlayerSharedControls: - storedSettings.webPlayerSharedControls !== - false, - portalConnectivityGuard: - storedSettings.portalConnectivityGuard !== - false, - embeddedMpvAutoReconnect: - storedSettings.embeddedMpvAutoReconnect !== - false, + ...resolveDefaultOnSettings(storedSettings), dashboardRails: normalizeDashboardRailsSettings( storedSettings.dashboardRails ), @@ -222,24 +215,7 @@ export const SettingsStore = signalStore( const previousEpgUrls = store.epgUrl(); patchState(store, { ...settings, - ...(settings.webPlayerSharedControls !== undefined - ? { - webPlayerSharedControls: - settings.webPlayerSharedControls !== false, - } - : {}), - ...(settings.portalConnectivityGuard !== undefined - ? { - portalConnectivityGuard: - settings.portalConnectivityGuard !== false, - } - : {}), - ...(settings.embeddedMpvAutoReconnect !== undefined - ? { - embeddedMpvAutoReconnect: - settings.embeddedMpvAutoReconnect !== false, - } - : {}), + ...coerceProvidedDefaultOnSettings(settings), ...(settings.dashboardRails !== undefined ? { dashboardRails: normalizeDashboardRailsSettings( @@ -344,6 +320,7 @@ export const SettingsStore = signalStore( store.embeddedMpvAutoReconnect?.() !== false, coverSize: store.coverSize?.() ?? DEFAULT_SETTINGS.coverSize, + showCoverTitles: store.showCoverTitles?.() !== false, epgViewMode: store.epgViewMode?.() ?? DEFAULT_SETTINGS.epgViewMode, epgOffsetMinutes: normalizeEpgOffsetMinutes( diff --git a/libs/shared/interfaces/src/lib/settings.interface.ts b/libs/shared/interfaces/src/lib/settings.interface.ts index b70b24634..c3c01c902 100644 --- a/libs/shared/interfaces/src/lib/settings.interface.ts +++ b/libs/shared/interfaces/src/lib/settings.interface.ts @@ -241,6 +241,15 @@ export interface Settings { portalConnectivityGuard?: boolean; /** Cover/poster sizing preset applied across grids and rails */ coverSize?: CoverSize; + /** + * Titles under movie/series covers. Default on; only an explicit false + * switches VOD, series, favorites and recent grids to a posters-only + * wall whose title appears as an overlay on hover/focus. Live channel + * grids, search results and the dashboard rails always keep their + * labels: logos are often missing, a search answers by name, and rails + * carry meta rows the overlay cannot hold. + */ + showCoverTitles?: boolean; /** Live EPG panel layout: horizontal timeline (default) or vertical list */ epgViewMode?: EpgViewMode; /** diff --git a/libs/ui/styles/_content-grid.scss b/libs/ui/styles/_content-grid.scss index 8906db632..110373d9d 100644 --- a/libs/ui/styles/_content-grid.scss +++ b/libs/ui/styles/_content-grid.scss @@ -106,6 +106,67 @@ } } +// Hover/focus caption for the posters-only wall (Settings > General > +// "Show titles under covers" off). Placed inside the poster wrapper, which +// must be `position: relative` with `overflow: hidden`; the consumer reveals +// it on `:hover`/`:focus-visible` and pins it with `--pinned` whenever the +// cover cannot identify the item (default poster, placeholder). +@mixin cover-title-overlay { + position: absolute; + inset: auto 0 0 0; + box-sizing: border-box; + padding: 28px 8px 8px; + color: #fff; + font-size: 0.78rem; + font-weight: 500; + line-height: 1.3; + text-align: center; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6); + background: linear-gradient( + 180deg, + rgba(0, 0, 0, 0) 0%, + rgba(0, 0, 0, 0.72) 50%, + rgba(0, 0, 0, 0.88) 100% + ); + opacity: 0; + pointer-events: none; + // Entrance under 300ms, ease-out: the caption answers a hover, it is + // not a state change worth choreographing. + transition: opacity 150ms ease-out; + + // The clamp lives on a child: `overflow: hidden` clips at the padding + // edge, so a clamped element with its own padding paints the third + // line into that padding. + .cover-title-overlay__text { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + word-break: break-word; + } + + &.cover-title-overlay--pinned { + opacity: 1; + } + + @media (prefers-reduced-motion: reduce) { + transition: none; + } +} + +// Draw the ring on the element that owns `overflow: hidden` (or on one of +// its ancestors): an outline on a clipped descendant is cut on every edge. +@mixin focus-ring-declarations { + outline: 2px solid var(--app-selection-color, #2f7bff); + outline-offset: 3px; +} + +@mixin card-focus-ring { + &:focus-visible { + @include focus-ring-declarations; + } +} + @mixin card-info { padding: 8px 10px;