feat(portals): posters-only cover wall for movie and series grids (#1604)

* feat(portals): posters-only cover wall for movie and series grids

Add `Settings.showCoverTitles` (Settings > General, default on). Turning it
off drops the title row under VOD/series covers in catalog, favorites and
recent grids and reveals the title as a bottom-gradient overlay on hover and
keyboard focus, pinned open for items whose cover is missing or failed.

`CoverTitlesService` is the single resolver: the opt-out AND a hover-capable
pointer, so touch-only devices keep their titles. Live channel grids, search
results, "recently added" rails and dashboard rails always keep labels.

Catalog and collection cards become keyboard buttons (role, tabindex,
aria-label, Enter/Space, focus ring) and poster alt text is the title. The
default-on boolean coercion moves into `settings-opt-out.util.ts` because
the settings store reached the max-lines limit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(portals): keep nested Remove key presses from activating the card

Enter/Space on the content card's nested Remove button bubbled into the
card's own key handlers: Enter opened the item before removing it and
Space opened it while cancelling the removal. Only keys pressed on the
card element itself now activate it. Regression spec added.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(portals): keep cover titles while an in-section search filters the grid

The posters-only wall exempts search results because they are identified
by the name the user typed; the category grid's own in-section filter is
the same case, so `app-grid-list` now keeps the title row while its
`searchTerm` is non-blank. Contract docs updated, regression spec added.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(portals): keep cover titles while the collection tab search is active

The unified favorites/recent tab filters by its own search term, so its
matches are identified by name like every other search result. The tab
now opts its cards out of the posters-only wall while the term is
non-blank. Contract docs updated, regression spec added.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(portals): move the card Remove control out of the button surface

An interactive control nested inside a role="button" is an invalid
accessibility structure. The content card's activation surface is now its
own inner element and the Remove button a sibling positioned over the
poster corner, labelled by its tooltip text. Spec asserts the control is
never a descendant of the button.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(portals): draw the collection card focus ring where it is not clipped

The card's overflow: hidden clipped an outline drawn on the inner
activation surface on every edge, so keyboard users saw no focus
indication. The ring now sits on the outer card via
:has(> .content-card__activation:focus-visible).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(portals): detect any hover-capable pointer for the posters-only wall

`hover` describes only the primary pointer, so a touch-first tablet with
a mouse or hover-capable stylus attached lost the wall. The resolver now
reads `(any-hover: hover)`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 authored and GitHub committed 2026-09-14 18:34:21 +02:00
1 parent 40092f6efc
commit ef3f98d026
49 files changed
+1297 -106

No files matched your search

+7
View File
@@ -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.
+2
View File
@@ -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:<surface>`); 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 `<html data-cover-size>` 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'`):
```
+42
View File
@@ -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<XtreamLiveStream> {
@@ -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 ??
@@ -108,6 +108,25 @@
</div>
</div>
<div class="setting-item">
<div class="setting-item__meta">
<h4 id="cover-titles-label">
{{ 'SETTINGS.COVER_TITLES' | translate }}
</h4>
<p id="cover-titles-description">
{{ 'SETTINGS.COVER_TITLES_DESCRIPTION' | translate }}
</p>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-checkbox
formControlName="showCoverTitles"
aria-labelledby="cover-titles-label"
aria-describedby="cover-titles-description"
data-test-id="cover-titles-toggle"
></mat-checkbox>
</div>
</div>
<div class="setting-item">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.STARTUP_BEHAVIOR' | translate }}</h4>
@@ -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;
@@ -115,6 +115,7 @@ export const DEFAULT_SETTINGS = {
embeddedMpvAutoReconnect: true,
portalConnectivityGuard: true,
coverSize: 'medium',
showCoverTitles: true,
dashboardRails: DEFAULT_DASHBOARD_RAILS,
preferUploadedEpgOverXtream: false,
epgViewMode: 'timeline',
+2
View File
@@ -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.",
+2
View File
@@ -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.",
+2
View File
@@ -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.",
+2
View File
@@ -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.",
+2
View File
@@ -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.",
+2
View File
@@ -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.",
+2
View File
@@ -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.",
+2
View File
@@ -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.",
+2
View File
@@ -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.",
+2
View File
@@ -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.",
+2
View File
@@ -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 までの分単位で指定してください。",
+2
View File
@@ -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 사이의 분 단위로 입력하세요.",
+2
View File
@@ -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.",
+2
View File
@@ -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.",
+2
View File
@@ -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.",
+2
View File
@@ -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.",
+2
View File
@@ -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.",
+2
View File
@@ -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 之间的分钟数。",
+2
View File
@@ -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 之間的分鐘數。",
@@ -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 `<html>` 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
+1
View File
@@ -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';
@@ -1,52 +1,84 @@
<div class="content-card" (click)="onCardClick()">
<div class="thumbnail-wrapper">
@if (showRemoveButton()) {
<button
mat-icon-button
class="remove-button"
[matTooltip]="removeTooltip()"
(click)="onRemoveClick($event)"
>
<mat-icon>close</mat-icon>
</button>
}
<div class="content-card">
<!-- The activation surface is its own element so the Remove control
below stays a sibling: an interactive control nested inside a
role="button" is an invalid accessibility structure. -->
<div
class="content-card__activation"
role="button"
tabindex="0"
[attr.aria-label]="title()"
(click)="onCardClick()"
(keydown.enter)="onCardKey($event)"
(keydown.space)="onCardKey($event)"
>
<div class="thumbnail-wrapper">
@if (posterUrl() && !artworkFailed()) {
<img
[src]="posterUrl()"
[alt]="title()"
(error)="onImageError()"
class="poster"
loading="lazy"
decoding="async"
/>
} @else if (showPlaceholder()) {
<div class="poster-placeholder">
<mat-icon>{{ getPlaceholderIcon() }}</mat-icon>
</div>
} @else {
<img
src="./assets/images/default-poster.png"
[alt]="title()"
class="poster"
loading="lazy"
decoding="async"
/>
}
@if (posterUrl()) {
<img
[src]="posterUrl()"
[alt]="title()"
(error)="onImageError($event)"
class="poster"
loading="lazy"
decoding="async"
/>
} @else if (showPlaceholder()) {
<div class="poster-placeholder">
<mat-icon>{{ getPlaceholderIcon() }}</mat-icon>
@if (postersOnly()) {
<!-- Hover/focus caption for the posters-only wall; pinned open
when the cover cannot identify the item on its own. -->
<div
class="cover-title-overlay"
[class.cover-title-overlay--pinned]="artworkMissing()"
aria-hidden="true"
>
<span class="cover-title-overlay__text">{{ title() }}</span>
</div>
}
@if (type() && showTypeBadge()) {
<span class="type-badge" [class]="type()">
{{ type() }}
</span>
}
</div>
@if (!postersOnly()) {
<div class="card-info">
<h3>{{ title() }}</h3>
@if (date()) {
<p class="date-info">
{{
date()
| date: 'd MMM, HH:mm' : undefined : currentLocale()
}}
</p>
}
</div>
} @else {
<img
src="./assets/images/default-poster.png"
[alt]="title()"
class="poster"
loading="lazy"
decoding="async"
/>
}
@if (type() && showTypeBadge()) {
<span class="type-badge" [class]="type()">
{{ type() }}
</span>
}
</div>
<div class="card-info">
<h3>{{ title() }}</h3>
@if (date()) {
<p class="date-info">
{{ date() | date: 'd MMM, HH:mm' : undefined : currentLocale() }}
</p>
}
</div>
@if (showRemoveButton()) {
<button
mat-icon-button
type="button"
class="remove-button"
[matTooltip]="removeTooltip()"
[attr.aria-label]="removeTooltip()"
(click)="onRemoveClick($event)"
>
<mat-icon>close</mat-icon>
</button>
}
</div>
@@ -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;
}
@@ -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<ContentCardComponent>;
let showCoverTitles: ReturnType<typeof signal<boolean>>;
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);
});
});
@@ -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<string> = 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<boolean>(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<boolean>(true);
/** Emitted when the card is clicked */
readonly cardClick = output<void>();
/** Emitted when the remove button is clicked */
readonly remove = output<void>();
/** 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);
}
}
@@ -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;
@@ -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<GridListComponent>;
let showCoverTitles: ReturnType<typeof signal<boolean>>;
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<GridListComponent>;
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);
});
});
@@ -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);
<mat-card
[class.grid-card--logo]="variant() === 'logo'"
role="button"
tabindex="0"
[attr.aria-label]="title"
(click)="itemClicked.emit(item)"
(keydown.enter)="itemClicked.emit(item)"
(keydown.space)="onSpaceKey($event, item)"
>
@let poster = resolvePoster(i);
@let artworkMissing = !poster || hasArtworkFailed(poster);
<div class="card-thumbnail-container">
@if (poster && !hasArtworkFailed(poster)) {
@if (!artworkMissing) {
<img
class="stream-icon"
[src]="poster"
(error)="onImageError($event, poster)"
loading="lazy"
alt="logo"
[alt]="title"
/>
} @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()) {
<!-- Hover/focus caption for the posters-only
wall; pinned open when the cover cannot
identify the item on its own. -->
<div
class="cover-title-overlay"
[class.cover-title-overlay--pinned]="
artworkMissing
"
aria-hidden="true"
>
<span class="cover-title-overlay__text">{{
title
}}</span>
</div>
}
@if (i.progress && i.progress > 0) {
<app-progress-capsule [progress]="i.progress" />
}
@@ -182,11 +206,11 @@ function normalizeArtworkUrl(value: string | undefined): string | undefined {
<mat-icon>star</mat-icon>{{ rating }}
</div>
}
<mat-card-actions>
<div class="title">
{{ channelTitle(i) }}
</div>
</mat-card-actions>
@if (!postersOnly()) {
<mat-card-actions>
<div class="title">{{ title }}</div>
</mat-card-actions>
}
</mat-card>
} @empty {
<div class="grid-empty-state">
@@ -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<ReadonlySet<string>>(new Set());
private readonly settingsStore = inject(SettingsStore);
private readonly coverTitles = inject(CoverTitlesService);
readonly items = input<GridListItem[]>([]);
readonly isLoading = input<boolean>(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 {
@@ -20,6 +20,7 @@
<div class="grid-container">
@for (item of filteredItems(); track trackByUid($index, item)) {
<app-content-card
[allowPostersOnly]="!hasActiveSearch()"
[posterUrl]="item.posterUrl ?? ''"
[title]="item.name"
[type]="item.contentType"
@@ -0,0 +1,91 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { MockPipe } from 'ng-mocks';
import { of } from 'rxjs';
import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
import { SettingsStore } from '@iptvnator/services';
import { UnifiedGridTabComponent } from './unified-grid-tab.component';
const ITEMS: UnifiedCollectionItem[] = [
{
uid: 'x:1:movie:1',
name: 'Blade Runner',
contentType: 'movie',
posterUrl: 'blade-runner.jpg',
} as UnifiedCollectionItem,
{
uid: 'x:1:movie:2',
name: 'Alien',
contentType: 'movie',
posterUrl: 'alien.jpg',
} as UnifiedCollectionItem,
];
describe('UnifiedGridTabComponent posters-only wall', () => {
let fixture: ComponentFixture<UnifiedGridTabComponent>;
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);
});
});
@@ -29,8 +29,19 @@ export class UnifiedGridTabComponent {
readonly removeItem = output<UnifiedCollectionItem>();
readonly itemSelected = output<UnifiedCollectionItem>();
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))
@@ -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<typeof signal<boolean | undefined>>;
let hoverMatches: boolean;
let listeners: ChangeListener[];
let removeListener: jest.Mock;
beforeEach(() => {
showCoverTitles = signal<boolean | undefined>(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]);
});
});
@@ -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<boolean> {
// 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();
}
@@ -56,6 +56,7 @@
<div class="results-grid">
@for (item of searchResults(); track item.id) {
<app-content-card
[allowPostersOnly]="false"
[title]="item.o_name || item.name || ''"
[posterUrl]="item.screenshot_uri"
[type]="selectedFilterType() === 'vod' ? 'movie' : 'series'"
@@ -32,6 +32,7 @@
>
@for (item of recentlyAddedVod(); track item.xtream_id) {
<app-content-card
[allowPostersOnly]="false"
[title]="item.title || item.name || ''"
[posterUrl]="item.poster_url || item.stream_icon"
[type]="'movie'"
@@ -52,6 +53,7 @@
>
@for (item of recentlyAddedSeries(); track item.xtream_id) {
<app-content-card
[allowPostersOnly]="false"
[title]="item.title || item.name || ''"
[posterUrl]="item.poster_url || item.cover"
[type]="'series'"
@@ -72,6 +74,7 @@
>
@for (item of recentlyAddedLive(); track item.xtream_id) {
<app-content-card
[allowPostersOnly]="false"
[title]="item.title || item.name || ''"
[posterUrl]="item.poster_url || item.stream_icon"
[type]="'live'"
@@ -120,6 +120,7 @@
<div class="results-grid">
@for (item of group.items; track item.id) {
<app-content-card
[allowPostersOnly]="false"
[posterUrl]="
item.poster_url ?? undefined
"
@@ -138,6 +139,7 @@
<div class="results-grid">
@for (item of xtreamStore.searchResults(); track item.id) {
<app-content-card
[allowPostersOnly]="false"
[posterUrl]="item.poster_url ?? undefined"
[title]="item.title"
[type]="getDisplayType(item)"
@@ -163,6 +165,7 @@
<div class="results-grid">
@for (item of visibleInPortalResults(); track item.id) {
<app-content-card
[allowPostersOnly]="false"
[posterUrl]="item.poster_url ?? undefined"
[title]="item.title"
[type]="item.type"
@@ -0,0 +1,31 @@
import {
coerceProvidedDefaultOnSettings,
resolveDefaultOnSettings,
} from './settings-opt-out.util';
describe('default-on settings coercion', () => {
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({});
});
});
@@ -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<Settings>
): Record<DefaultOnSetting, boolean> {
// `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<DefaultOnSetting, boolean>
);
}
/** Only the default-on flags the caller supplied, coerced the same way. */
export function coerceProvidedDefaultOnSettings(
settings: Partial<Settings>
): Partial<Record<DefaultOnSetting, boolean>> {
return DEFAULT_ON_SETTINGS.reduce(
(coerced, key) => {
if (settings[key] !== undefined) {
coerced[key] = settings[key] !== false;
}
return coerced;
},
{} as Partial<Record<DefaultOnSetting, boolean>>
);
}
@@ -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);
@@ -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(
@@ -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;
/**
+61
View File
@@ -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;