style(ui): one segmented control for toggles, season tabs and the switcher (#1954)

* style(ui): one segmented control for toggles, season tabs and the switcher

The favorites and recent content and scope toggles, the episode list/grid
switch and the season tabs (up to four seasons) become the kit's segmented
control, the one the playlist switcher's type filter already used: a
1px-bordered track with 2px padding, 8px segments, no dividers, the chosen
segment filled. In a header row the control is 32px tall like a small
button. The season tabs keep their tab semantics: `segmented-track` and
`segment` build the look from plain buttons, and only the track is the tab
list, so "Back to playing" no longer sits inside it.

The `segmented-control` mixin's label padding and check rules never matched:
Material renders those elements without the including component's
encapsulation attribute. They now go through `::ng-deep` inside the mixin,
which also takes an icon size and a fixed segment width for icon-only
segments. Unchosen labels move from the muted colour (about 2.9:1 on the
track) to the body colour.

The language filter in the VOD sources popover is no longer a native
select: a filter chip with the kit's chevron opens a radio menu. Escape
closes only that menu. A new PORTALS.MULTI_SOURCE.LANGUAGE key names it,
translated in every locale.

Docs: segmented control rule in the UI guidelines; stale season-picker
facts (six seasons, dropdown thumbnails) corrected against the code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ui): wrap a squeezed segmented control instead of clipping it

The season tabs used to wrap pill by pill; inside the new track four
translated season names overflowed the fullscreen episode panel, which
clips, and the last tab was cut off. `segmented-track` now keeps the
control within its container and wraps the segments inside the track,
for the Material toggle groups as well. The E2E squeezes a season track
and the scope toggle and checks that no segment leaves its frame, and it
now closes its playlist server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 authored and GitHub committed 2026-10-16 08:53:53 +02:00
1 parent 3cc10966a3
commit a76fb70965
38 files changed
+709 -208

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
---
type: fix
area: ui
---
The favorites and recent type and scope toggles, the season tabs, the
episode list/grid switch and the playlist switcher's type filter now share
one segmented control with easier-to-read labels. The language filter in the
alternative sources popover opens a menu instead of the system dropdown.
@@ -0,0 +1,217 @@
import type { Locator, Page } from '@playwright/test';
import {
addCurrentDetailToFavorites,
goBackFromDetail,
} from './dashboard-e2e-flows';
import {
addXtreamPortal,
buildM3uContent,
closeElectronApp,
createMutableTextServer,
expect,
importM3uPlaylistFromUrl,
launchElectronApp,
openGlobalFavorites,
openWorkspaceSection,
resetMockServers,
test,
waitForXtreamWorkspaceReady,
} from './electron-test-fixtures';
import { applyTheme, expectTextContrast } from './theme-contrast';
// ---------------------------------------------------------------------------
// Every single choice shown side by side is the kit's segmented control: the
// favorites content and scope toggles, the season tabs, the episode list/grid
// switch and the playlist switcher's type filter. In a header row it is 32px
// tall (26px segments in a 2px-padded, 1px-bordered track); the switcher
// panel keeps 30px segments. Unchosen labels must stay legible in both
// themes: the muted colour read about 2.9:1 on the track. A control never
// grows past its container: squeezed, its segments wrap inside the track
// rather than being clipped (four translated season names in the fullscreen
// episode panel).
// ---------------------------------------------------------------------------
async function frame(locator: Locator) {
return locator.evaluate((element) => {
const style = getComputedStyle(element);
return {
height: Math.round(element.getBoundingClientRect().height),
radius: style.borderRadius,
};
});
}
/** Labels of the segments that stick out of the control's own frame. */
async function segmentsOutside(control: Locator): Promise<string[]> {
return control.evaluate((element) => {
const frame = element.getBoundingClientRect();
return Array.from(element.children)
.filter((segment) => {
const rect = segment.getBoundingClientRect();
return (
rect.left < frame.left - 0.5 ||
rect.right > frame.right + 0.5 ||
rect.bottom > frame.bottom + 0.5
);
})
.map((segment) => segment.textContent?.trim() ?? '');
});
}
async function expectUnchosenLegible(page: Page, unchosen: Locator[]) {
for (const theme of ['light', 'dark'] as const) {
await applyTheme(page, theme);
for (const segment of unchosen) {
await expectTextContrast(segment);
}
}
await applyTheme(page, 'light');
}
test.describe('Electron segmented controls', () => {
test('draws the season tabs, list/grid switch, favorites toggles and switcher type filter as one legible segmented control', async ({
dataDir,
request,
}) => {
test.setTimeout(120_000);
await resetMockServers(request, ['xtream']);
// A second source kind is what shows the switcher's type filter.
const m3u = await createMutableTextServer(
buildM3uContent([
{
name: 'Segment Channel',
url: 'https://example.com/live/segment.m3u8',
},
]),
{
contentType: 'application/x-mpegurl; charset=utf-8',
resourcePath: '/segmented.m3u',
}
);
let app: Awaited<ReturnType<typeof launchElectronApp>> | undefined;
try {
app = await launchElectronApp(dataDir);
const page = app.mainWindow;
await page.route('https://example.com/**', (route) =>
route.abort()
);
await importM3uPlaylistFromUrl(page, m3u.resourceUrl);
await addXtreamPortal(page);
await waitForXtreamWorkspaceReady(page);
await page.setViewportSize({ width: 1280, height: 760 });
// A three-season series: the tabs, not the season picker.
await openWorkspaceSection(page, 'Series');
const seriesCard = page
.locator('app-category-content-view mat-card')
.first();
await expect(seriesCard).toBeVisible({ timeout: 30_000 });
await seriesCard.click();
const tablist = page.locator(
'app-season-tabs .season-tabs__track[role="tablist"]'
);
await expect(tablist).toBeVisible({ timeout: 30_000 });
const tabs = tablist.getByRole('tab');
await expect(tabs).toHaveCount(3);
await expect(tablist.locator(':scope > *')).toHaveCount(3);
expect(await frame(tablist)).toEqual({
height: 32,
radius: '10px',
});
const viewSwitch = page.locator(
'.view-toggle mat-button-toggle-group'
);
expect(await frame(viewSwitch)).toEqual({
height: 32,
radius: '10px',
});
await expectUnchosenLegible(page, [
tabs.nth(1),
viewSwitch.locator(
'.mat-button-toggle:not(.mat-button-toggle-checked)'
),
]);
// Squeezed below its segments' width, the track wraps them and
// stays inside the tabs' host.
await tablist.evaluate((element) => {
(
element.closest('app-season-tabs') as HTMLElement
).style.width = '140px';
});
expect(await segmentsOutside(tablist)).toEqual([]);
expect(
await tablist.evaluate(
(element) =>
element.getBoundingClientRect().right <=
(
element.closest('app-season-tabs') as HTMLElement
).getBoundingClientRect().right +
0.5
)
).toBe(true);
await tablist.evaluate((element) => {
(
element.closest('app-season-tabs') as HTMLElement
).style.width = '';
});
await addCurrentDetailToFavorites(page);
await goBackFromDetail(page);
await openWorkspaceSection(page, 'Movies');
const movieCard = page
.locator('app-category-content-view mat-card')
.first();
await expect(movieCard).toBeVisible({ timeout: 30_000 });
await movieCard.click();
await addCurrentDetailToFavorites(page);
await goBackFromDetail(page);
await openGlobalFavorites(page);
const header = page
.locator('app-unified-collection-page .collection-header')
.first();
const contentToggle = header.locator('.content-toggle');
const scopeToggle = header.locator('.scope-toggle');
await expect(contentToggle).toBeVisible({ timeout: 20_000 });
for (const toggle of [contentToggle, scopeToggle]) {
expect(await frame(toggle)).toEqual({
height: 32,
radius: '10px',
});
}
const unchosen =
'.mat-button-toggle:not(.mat-button-toggle-checked)';
await expectUnchosenLegible(page, [
contentToggle.locator(unchosen),
scopeToggle.locator(unchosen),
]);
// A Material group wraps its toggles the same way.
await scopeToggle.evaluate((element) => {
(element as HTMLElement).style.width = '120px';
});
expect(await segmentsOutside(scopeToggle)).toEqual([]);
await scopeToggle.evaluate((element) => {
(element as HTMLElement).style.width = '';
});
await page.getByTestId('playlist-switcher-trigger').click();
const types = page.locator(
'app-playlist-switcher-panel .switcher__types'
);
await expect(types).toBeVisible({ timeout: 10_000 });
expect(await frame(types)).toEqual({
height: 36,
radius: '10px',
});
await expectUnchosenLegible(page, [
types.locator(unchosen).first(),
]);
await page.keyboard.press('Escape');
} finally {
if (app) await closeElectronApp(app);
await m3u.close();
}
});
});
+1
View File
@@ -1480,6 +1480,7 @@
"FILTER_AVAILABLE": "متاح",
"FILTER_HD": "HD+",
"ALL_LANGUAGES": "كل اللغات",
"LANGUAGE": "اللغة",
"SAME_AS_ABOVE": "كما في الأعلى",
"SHOWING_OF_TOTAL": "{{visible}} من {{total}}"
},
+1
View File
@@ -1480,6 +1480,7 @@
"FILTER_AVAILABLE": "متوفر",
"FILTER_HD": "HD+",
"ALL_LANGUAGES": "كل اللغات",
"LANGUAGE": "اللغة",
"SAME_AS_ABOVE": "بحال اللي الفوق",
"SHOWING_OF_TOTAL": "{{visible}} من {{total}}"
},
+1
View File
@@ -1480,6 +1480,7 @@
"FILTER_AVAILABLE": "Даступныя",
"FILTER_HD": "HD+",
"ALL_LANGUAGES": "Усе мовы",
"LANGUAGE": "Мова",
"SAME_AS_ABOVE": "як радком вышэй",
"SHOWING_OF_TOTAL": "{{visible}} з {{total}}"
},
+1
View File
@@ -1480,6 +1480,7 @@
"FILTER_AVAILABLE": "Verfügbar",
"FILTER_HD": "HD+",
"ALL_LANGUAGES": "Alle Sprachen",
"LANGUAGE": "Sprache",
"SAME_AS_ABOVE": "wie oben",
"SHOWING_OF_TOTAL": "{{visible}} von {{total}}"
},
+1
View File
@@ -1480,6 +1480,7 @@
"FILTER_AVAILABLE": "Διαθέσιμα",
"FILTER_HD": "HD+",
"ALL_LANGUAGES": "Όλες οι γλώσσες",
"LANGUAGE": "Γλώσσα",
"SAME_AS_ABOVE": "ίδιο με το παραπάνω",
"SHOWING_OF_TOTAL": "{{visible}} από {{total}}"
},
+1
View File
@@ -1480,6 +1480,7 @@
"FILTER_AVAILABLE": "Available",
"FILTER_HD": "HD+",
"ALL_LANGUAGES": "All languages",
"LANGUAGE": "Language",
"SAME_AS_ABOVE": "same as above",
"SHOWING_OF_TOTAL": "{{visible}} of {{total}}"
},
+1
View File
@@ -1480,6 +1480,7 @@
"FILTER_AVAILABLE": "Disponibles",
"FILTER_HD": "HD+",
"ALL_LANGUAGES": "Todos los idiomas",
"LANGUAGE": "Idioma",
"SAME_AS_ABOVE": "igual que la anterior",
"SHOWING_OF_TOTAL": "{{visible}} de {{total}}"
},
+1
View File
@@ -1480,6 +1480,7 @@
"FILTER_AVAILABLE": "Disponibles",
"FILTER_HD": "HD+",
"ALL_LANGUAGES": "Toutes les langues",
"LANGUAGE": "Langue",
"SAME_AS_ABOVE": "identique à la ligne au-dessus",
"SHOWING_OF_TOTAL": "{{visible}} sur {{total}}"
},
+1
View File
@@ -1480,6 +1480,7 @@
"FILTER_AVAILABLE": "Elérhető",
"FILTER_HD": "HD+",
"ALL_LANGUAGES": "Minden nyelv",
"LANGUAGE": "Nyelv",
"SAME_AS_ABOVE": "ugyanaz, mint fent",
"SHOWING_OF_TOTAL": "{{visible}} / {{total}}"
},
+1
View File
@@ -1480,6 +1480,7 @@
"FILTER_AVAILABLE": "Disponibili",
"FILTER_HD": "HD+",
"ALL_LANGUAGES": "Tutte le lingue",
"LANGUAGE": "Lingua",
"SAME_AS_ABOVE": "uguale alla riga sopra",
"SHOWING_OF_TOTAL": "{{visible}} di {{total}}"
},
+1
View File
@@ -1480,6 +1480,7 @@
"FILTER_AVAILABLE": "利用可能",
"FILTER_HD": "HD+",
"ALL_LANGUAGES": "すべての言語",
"LANGUAGE": "言語",
"SAME_AS_ABOVE": "上と同じ",
"SHOWING_OF_TOTAL": "{{total}}件中{{visible}}件"
},
+1
View File
@@ -1480,6 +1480,7 @@
"FILTER_AVAILABLE": "사용 가능",
"FILTER_HD": "HD+",
"ALL_LANGUAGES": "모든 언어",
"LANGUAGE": "언어",
"SAME_AS_ABOVE": "위와 동일",
"SHOWING_OF_TOTAL": "{{total}}개 중 {{visible}}개"
},
+1
View File
@@ -1480,6 +1480,7 @@
"FILTER_AVAILABLE": "Beschikbaar",
"FILTER_HD": "HD+",
"ALL_LANGUAGES": "Alle talen",
"LANGUAGE": "Taal",
"SAME_AS_ABOVE": "zelfde als hierboven",
"SHOWING_OF_TOTAL": "{{visible}} van {{total}}"
},
+1
View File
@@ -1480,6 +1480,7 @@
"FILTER_AVAILABLE": "Dostępne",
"FILTER_HD": "HD+",
"ALL_LANGUAGES": "Wszystkie języki",
"LANGUAGE": "Język",
"SAME_AS_ABOVE": "tak samo jak wyżej",
"SHOWING_OF_TOTAL": "{{visible}} z {{total}}"
},
+1
View File
@@ -1480,6 +1480,7 @@
"FILTER_AVAILABLE": "Disponíveis",
"FILTER_HD": "HD+",
"ALL_LANGUAGES": "Todos os idiomas",
"LANGUAGE": "Idioma",
"SAME_AS_ABOVE": "igual à linha acima",
"SHOWING_OF_TOTAL": "{{visible}} de {{total}}"
},
+1
View File
@@ -1480,6 +1480,7 @@
"FILTER_AVAILABLE": "Доступные",
"FILTER_HD": "HD+",
"ALL_LANGUAGES": "Все языки",
"LANGUAGE": "Язык",
"SAME_AS_ABOVE": "как строкой выше",
"SHOWING_OF_TOTAL": "{{visible}} из {{total}}"
},
+1
View File
@@ -1480,6 +1480,7 @@
"FILTER_AVAILABLE": "Kullanılabilir",
"FILTER_HD": "HD+",
"ALL_LANGUAGES": "Tüm diller",
"LANGUAGE": "Dil",
"SAME_AS_ABOVE": "üsttekiyle aynı",
"SHOWING_OF_TOTAL": "{{visible}} / {{total}}"
},
+1
View File
@@ -1480,6 +1480,7 @@
"FILTER_AVAILABLE": "可用",
"FILTER_HD": "HD+",
"ALL_LANGUAGES": "所有语言",
"LANGUAGE": "语言",
"SAME_AS_ABOVE": "与上行相同",
"SHOWING_OF_TOTAL": "{{visible}} / {{total}}"
},
+1
View File
@@ -1480,6 +1480,7 @@
"FILTER_AVAILABLE": "可用",
"FILTER_HD": "HD+",
"ALL_LANGUAGES": "所有語言",
"LANGUAGE": "語言",
"SAME_AS_ABOVE": "與上行相同",
"SHOWING_OF_TOTAL": "{{visible}} / {{total}}"
},
+6 -13
View File
@@ -309,19 +309,12 @@ strip (poster, season name, episode count — `PORTALS.EPISODE_COUNT_ONE` /
same gates, from `PortalInlinePlayerComponent.seasonPosters` through
`buildFullscreenEpisodePanelSeasons`.
Beyond six seasons the tabs become a `mat-menu` dropdown, and that dropdown
carries **season thumbnails** from the same `seasonPosters` map
(`SeasonTabsComponent.seasonPosters`, passed by the season container and by
the fullscreen episode panel): a 28×42 poster projected into the leading slot
of each menu row that has one, and the selected season's poster inside the
closed trigger (`season-tabs__dropdown--with-thumb` tightens the pill around
it). A season without a poster gets no placeholder tile — its row simply
starts with the text — and a poster whose image request fails is dropped from
both places rather than left as a broken-image frame (the component keeps its
own failed-URL set, independent of the container's cover column). The pill
row (six seasons or fewer) deliberately stays text-only: the design review
rejected per-pill thumbnails as a second poster rail, and the season cover
beside the tabs already shows the selected season's picture.
`SeasonTabsComponent` draws both forms of the picker, on the detail page and
in the fullscreen episode panel. Up to four seasons the tabs are the kit's
segmented control (`role="tablist"` on the track only; the "back to playing
episode" chip sits beside it). From five, the kit's toolbar picker (a small
secondary button with a chevron) opens a `mat-menu` of radio rows. Neither
form draws season artwork.
### Manual watched toggle for movies
+20 -7
View File
@@ -1061,13 +1061,12 @@ and drive the season container's presenters.
Every section below the hero (Episodes, Cast & crew, Similar) uses
`app-detail-section-header`: an 18px/600 title, a muted 12.5px tabular
counter, a lead slot (the 30px season pills, or past four seasons the
season dropdown, a small secondary button with a chevron) and an end slot
(the list/grid toggle, rail arrows). Sections sit 56px apart, with 16px
counter, a lead slot (up to four seasons the season tabs, a segmented
control; past four the season dropdown, a small secondary button with a
chevron) and an end slot (the list/grid toggle, rail arrows). Sections sit 56px apart, with 16px
between a header and its content and 56px after the last one. The Episodes
counter reads "N episodes · M watched". The list/grid switch is a segmented
track whose checked segment is a neutral raised fill with heading ink; the
list is the default view.
counter reads "N episodes · M watched". The list/grid switch is the same
segmented control with 30×26 icon segments; the list is the default view.
Episodes render through `app-episode-item` as a flat list row or grid card,
never a bordered box. A row is a number column, a 168px 16:9 thumbnail (with
@@ -1265,7 +1264,21 @@ current value where there is one; when the visible label is only the value,
the accessible name adds what it picks ("Channels shown: Favorites"). An
active picker (refinements applied) takes the selected-row tint. A panel
header's icon actions use `ghost-icon-button`, as the category panel's
search and sort do.
search and sort do. In a row of filter chips (the VOD sources popover) the
picker is a chip with the same chevron, so the row keeps one height; the
language filter there opens a radio menu instead of a native `<select>`.
A single choice among a few options shown side by side is the kit's
segmented control: a 1px-bordered track with 2px padding and a 10px radius,
8px segments, no dividers, the chosen segment filled and in the heading
colour. `segmented-control($height, $icon-size, $segment-width)` dresses a
`mat-button-toggle-group` (the favorites and recent content and scope
toggles, the list/grid switch, the playlist switcher's type filter);
`segmented-track` and `segment` build it from plain buttons that are tabs
(the season tabs). In a header row use 26px segments, so the control is
32px tall like a small button. Unchosen labels use the body colour; the
muted colour reads about 2.9:1 on the track. The settings theme switcher is
still a pill switcher of its own.
The add-source forms (M3U URL, Xtream, Stalker) and the edit dialog share one
vocabulary, so a field reads the same wherever it appears:
@@ -933,7 +933,7 @@ be.
the shared ≥90 % `watched` rule; the playing row is the one whose id equals
`contentInfo.contentXtreamId`.
- Body: `app-fullscreen-episode-panel` stamps `SeasonTabsComponent` (the
detail page's tabs: pills up to six seasons, a dropdown beyond, watched
detail page's tabs: segmented tabs up to four seasons, a menu beyond, watched
check marks, the "Back to playing episode" chip) over the selected season's
rows: a 16:9 still or, without one, a large numeral tile so the no-TMDB
case still looks designed; the `S01E03` label, runtime, watched check or
+2 -2
View File
@@ -156,8 +156,8 @@ fit contract:
The chip row composes with the host search (AND): **All (N)** resets the chip
filters and states the total copy count, **Available** keeps only sources
whose probe verified them, **HD+** keeps sources whose quality tag reads
1080p or better, and the language select is built from the languages actually
present in the list. Two of these encode a decision worth writing down:
1080p or better, and the language chip (a filter chip with a chevron that
opens a radio menu) offers the languages actually present in the list. Two of these encode a decision worth writing down:
- "Available" is strict: only `probe.status === 'ok'` passes. An unchecked
source must never pass a filter with that name — and because checks are
@@ -119,8 +119,8 @@
margin: 0 14px 8px;
}
// The segment's 6px gap separates the count from its label.
.switcher__type-count {
margin-inline-start: 4px;
font-size: 11px;
color: var(--app-muted-color);
}
@@ -136,15 +136,11 @@
>
<mat-button-toggle value="playlist">
<mat-icon>playlist_play</mat-icon>
<span class="toggle-label">{{
'PORTALS.THIS_PLAYLIST' | translate
}}</span>
<span>{{ 'PORTALS.THIS_PLAYLIST' | translate }}</span>
</mat-button-toggle>
<mat-button-toggle value="all">
<mat-icon>public</mat-icon>
<span class="toggle-label">{{
'PORTALS.ALL_PLAYLISTS' | translate
}}</span>
<span>{{ 'PORTALS.ALL_PLAYLISTS' | translate }}</span>
</mat-button-toggle>
</mat-button-toggle-group>
}
@@ -1,4 +1,5 @@
@use '@angular/material' as mat;
@use '../../../../../../../ui/styles/app-controls' as controls;
@use '../../styles/skeleton';
:host {
@@ -91,29 +92,25 @@
margin-left: auto;
}
// The loading header holds the toggles' place: a segmented control's 32px
// frame and 10px radius, as wide as the English controls (three content
// types; This playlist / All playlists).
.toggle-placeholder {
height: 43px;
width: 120px;
border-radius: 18px;
height: 32px;
width: 184px;
border-radius: 10px;
background: color-mix(in srgb, var(--mat-sys-on-surface) 6%, transparent);
&--scope {
width: 200px;
width: 222px;
}
}
// The kit's segmented control at the small-button height, the content types
// beside the scope (This playlist / All playlists).
.content-toggle,
.scope-toggle {
.toggle-label {
margin-left: 4px;
font-size: 0.75rem;
}
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
@include controls.segmented-control(26px);
}
app-unified-live-tab,
@@ -1,3 +1,5 @@
@use '../../../../styles/app-controls' as controls;
.seasons-section {
width: 100%;
}
@@ -69,64 +71,10 @@
min-width: 0;
}
// Segmented list/grid switch: a 2px-padded track with 30×26 segments;
// the checked one is a neutral raised segment with full-contrast ink.
// The list/grid switch: the kit's segmented control with 30×26
// icon-only segments, 32px tall like the season picker in this header.
.view-toggle mat-button-toggle-group {
gap: 2px;
height: auto;
padding: 2px;
overflow: visible;
border: 1px solid
color-mix(in srgb, var(--app-on-surface) 14%, transparent);
border-radius: 8px;
background: var(--app-widget-bg);
::ng-deep {
.mat-button-toggle {
border: 0;
border-radius: 6px;
background: transparent;
color: var(--app-eyebrow-color);
.mat-button-toggle-button {
width: 30px;
height: 26px;
padding: 0;
}
.mat-button-toggle-label-content {
display: grid;
place-items: center;
padding: 0;
line-height: 26px;
}
mat-icon {
width: 16px;
height: 16px;
font-size: 16px;
line-height: 16px;
color: inherit;
}
&:hover:not(.mat-button-toggle-checked) {
color: var(--app-heading-color);
}
&.mat-button-toggle-checked {
background: color-mix(
in srgb,
var(--app-on-surface) 12%,
transparent
);
color: var(--app-heading-color);
}
}
.mat-button-toggle + .mat-button-toggle {
border-left: 0;
}
}
@include controls.segmented-control(26px, 16px, 30px);
}
}
@@ -460,6 +460,35 @@ describe('SeasonContainerComponent', () => {
).not.toBeNull();
});
it('keeps only the season tabs in the tab list, with Back to playing beside it', () => {
fixture.componentRef.setInput('playingEpisodeId', 101);
setRequiredInputs({
'1': [createEpisode()],
'2': [createEpisode({ id: '201', season: 2 })],
});
fixture.detectChanges();
(
fixture.nativeElement.querySelectorAll(
'.season-tabs__pill'
)[1] as HTMLButtonElement
).click();
fixture.detectChanges();
const tablists = fixture.nativeElement.querySelectorAll(
'app-season-tabs [role="tablist"]'
) as NodeListOf<HTMLElement>;
expect(tablists).toHaveLength(1);
expect(
Array.from(tablists[0].children).map((child) =>
child.getAttribute('role')
)
).toEqual(['tab', 'tab']);
const backToPlaying = fixture.nativeElement.querySelector(
'[data-testid="back-to-playing"]'
) as HTMLElement;
expect(backToPlaying.closest('[role="tablist"]')).toBeNull();
});
it('marks the inline-playing episode as playing and current', () => {
fixture.componentRef.setInput('playingEpisodeId', 101);
setRequiredInputs({ '1': [createEpisode()] });
@@ -1,4 +1,4 @@
<div class="season-tabs" [attr.role]="useDropdown() ? null : 'tablist'">
<div class="season-tabs">
@if (useDropdown()) {
<button
mat-flat-button
@@ -59,23 +59,29 @@
}
</mat-menu>
} @else {
@for (seasonKey of seasonKeys(); track seasonKey) {
<button
class="season-tabs__pill"
type="button"
role="tab"
[attr.aria-selected]="seasonKey === selectedSeason()"
[class.season-tabs__pill--active]="
seasonKey === selectedSeason()
"
(click)="selectSeason(seasonKey)"
>
{{ 'PORTALS.SEASON_TAB' | translate: { number: seasonKey } }}
@if (isSeasonCompleted(seasonKey)) {
<mat-icon class="season-tabs__done">check</mat-icon>
}
</button>
}
<!-- Only the tabs sit in the tab list; "Back to playing" is a
separate action beside it. -->
<div class="season-tabs__track" role="tablist">
@for (seasonKey of seasonKeys(); track seasonKey) {
<button
class="season-tabs__pill"
type="button"
role="tab"
[attr.aria-selected]="seasonKey === selectedSeason()"
[class.season-tabs__pill--active]="
seasonKey === selectedSeason()
"
(click)="selectSeason(seasonKey)"
>
{{
'PORTALS.SEASON_TAB' | translate: { number: seasonKey }
}}
@if (isSeasonCompleted(seasonKey)) {
<mat-icon class="season-tabs__done">check</mat-icon>
}
</button>
}
</div>
}
@if (showBackToPlaying()) {
@@ -11,40 +11,22 @@
flex-wrap: wrap;
gap: 4px;
// Season pills share the section header's 30px row; past four seasons
// the kit's toolbar picker (a small secondary button with a chevron)
// opens the season menu instead.
// Up to four seasons are tabs in the kit's segmented control: 26px
// segments in a 32px track. Past four the toolbar picker, a small
// secondary button with a chevron, opens the season menu instead. Both
// are as tall as the list/grid switch at the header's far edge.
&__track {
@include controls.segmented-track;
}
&__pill {
display: inline-flex;
align-items: center;
gap: 6px;
height: 30px;
padding: 0 12px;
border: 1px solid transparent;
border-radius: 999px;
box-sizing: border-box;
background: transparent;
color: var(--text-secondary, rgba(255, 255, 255, 0.7));
font-size: 13px;
font-family: inherit;
cursor: pointer;
transition:
background 150ms ease,
color 150ms ease;
&:hover {
background: var(--tag-bg, rgba(255, 255, 255, 0.08));
}
@include controls.segment;
&--active {
border-color: color-mix(
in srgb,
var(--app-on-surface) 14%,
transparent
);
background: var(--app-widget-bg);
color: var(--text-primary, #fff);
font-weight: 500;
@include controls.segment-selected;
}
}
@@ -0,0 +1,113 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { VodSourceDescriptor } from '@iptvnator/shared/interfaces';
import { VodSourcesChipComponent } from './vod-sources-chip.component';
function createSource(
overrides: Partial<VodSourceDescriptor>
): VodSourceDescriptor {
return {
id: 'playlist-1:xtream:42',
playlistId: 'playlist-1',
playlistName: 'Portal',
portalType: 'xtream',
contentId: 42,
rawTitle: 'Example Movie',
matchConfidence: 'exact',
year: 2024,
isActive: false,
isPinned: false,
isTried: false,
probe: { status: 'idle' },
...overrides,
};
}
describe('VodSourcesChipComponent', () => {
let fixture: ComponentFixture<VodSourcesChipComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [VodSourcesChipComponent, TranslateModule.forRoot()],
}).compileComponents();
fixture = TestBed.createComponent(VodSourcesChipComponent);
fixture.componentRef.setInput('sources', [
createSource({
id: 'a:xtream:1',
playlistId: 'a',
playlistName: 'Portal A',
rawTitle: 'EN| Example Movie (2024)',
}),
createSource({
id: 'b:xtream:2',
playlistId: 'b',
playlistName: 'Portal B',
rawTitle: 'DE| Example Movie (2024)',
}),
]);
fixture.detectChanges();
});
it('closes only the language menu on Escape: the sources popover stays open', async () => {
(
fixture.nativeElement.querySelector(
'.sources-chip'
) as HTMLButtonElement
).click();
fixture.detectChanges();
const languageChip = document.querySelector<HTMLButtonElement>(
'.sources-menu__lang'
);
expect(languageChip).not.toBeNull();
languageChip?.focus();
languageChip?.click();
fixture.detectChanges();
await fixture.whenStable();
expect(languageChip?.getAttribute('aria-expanded')).toBe('true');
const row = document.querySelector<HTMLElement>(
'.sources-language-menu [role="menuitemradio"]'
);
expect(row).not.toBeNull();
// The overlay dispatcher hands a keydown to the topmost overlay
// only: the language menu, not the popover beneath it.
row?.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Escape',
keyCode: 27,
bubbles: true,
})
);
fixture.detectChanges();
await fixture.whenStable();
expect(languageChip?.getAttribute('aria-expanded')).toBe('false');
expect(fixture.componentInstance.isOpen()).toBe(true);
expect(document.querySelector('.sources-menu__lang')).toBe(
languageChip
);
// Material detaches the menu after its exit animation; with the
// language menu gone, the next Escape reaches the popover.
for (
let wait = 0;
wait < 20 && document.querySelector('.sources-language-menu');
wait++
) {
await new Promise((resolve) => setTimeout(resolve, 50));
}
expect(document.querySelector('.sources-language-menu')).toBeNull();
languageChip?.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Escape',
keyCode: 27,
bubbles: true,
})
);
fixture.detectChanges();
expect(fixture.componentInstance.isOpen()).toBe(false);
});
});
@@ -98,21 +98,58 @@
}
@if (languages().length > 0) {
<select
class="sources-menu__lang"
[ngModel]="filters().language"
(ngModelChange)="setLanguageFilter($event)"
@let language = filters().language;
@let languageLabel =
language ?? ('PORTALS.MULTI_SOURCE.ALL_LANGUAGES' | translate);
<!-- A chip like its neighbours, with the kit's chevron: it opens
the language menu. The name says what it picks. -->
<button
type="button"
class="filter-chip sources-menu__lang"
[class.filter-chip--on]="language !== null"
[matMenuTriggerFor]="languageMenu"
[attr.aria-label]="
'PORTALS.MULTI_SOURCE.ALL_LANGUAGES' | translate
('PORTALS.MULTI_SOURCE.LANGUAGE' | translate) +
': ' +
languageLabel
"
>
<option [ngValue]="null">
{{ 'PORTALS.MULTI_SOURCE.ALL_LANGUAGES' | translate }}
</option>
@for (lang of languages(); track lang) {
<option [ngValue]="lang">{{ lang }}</option>
}
</select>
<span class="sources-menu__lang-label">{{
languageLabel
}}</span>
<mat-icon class="sources-menu__lang-chevron" aria-hidden="true"
>expand_more</mat-icon
>
</button>
<mat-menu #languageMenu="matMenu" class="sources-language-menu">
<div
role="group"
[attr.aria-label]="
'PORTALS.MULTI_SOURCE.LANGUAGE' | translate
"
>
<button
mat-menu-item
[appMenuItemRadio]="language === null"
(click)="setLanguageFilter(null)"
>
<mat-icon appMenuItemRadioCheck>check</mat-icon>
<span>{{
'PORTALS.MULTI_SOURCE.ALL_LANGUAGES' | translate
}}</span>
</button>
@for (lang of languages(); track lang) {
<button
mat-menu-item
[appMenuItemRadio]="language === lang"
(click)="setLanguageFilter(lang)"
>
<mat-icon appMenuItemRadioCheck>check</mat-icon>
<span>{{ lang }}</span>
</button>
}
</div>
</mat-menu>
}
</div>
@@ -1,3 +1,4 @@
@use '../../../../styles/app-controls' as controls;
@use '../../../../styles/focus-ring';
$font-mono: ui-monospace, 'SF Mono', Menlo, monospace;
@@ -264,22 +265,27 @@ $font-mono: ui-monospace, 'SF Mono', Menlo, monospace;
white-space: nowrap;
}
// The language filter: a `filter-chip` at the row's end that opens the
// language menu, on while a language is picked.
.sources-menu__lang {
margin-left: auto;
min-width: 0;
max-width: 40%;
padding: 5px 9px;
border: 1px solid var(--app-widget-border);
border-radius: 999px;
background: var(--app-card-hover-bg);
color: var(--app-eyebrow-color);
font: inherit;
font-size: 11px;
font-weight: 600;
outline: 0;
cursor: pointer;
margin-left: auto;
padding-inline-end: 8px;
}
&:focus-visible {
@include focus-ring.focus-ring-declarations;
.sources-menu__lang-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sources-menu__lang-chevron {
@include controls.trigger-chevron;
.filter-chip--on & {
color: inherit;
}
}
@@ -39,6 +39,7 @@ class FakeTranslateLoader implements TranslateLoader {
FILTER_AVAILABLE: 'Available',
FILTER_HD: 'HD+',
ALL_LANGUAGES: 'All languages',
LANGUAGE: 'Language',
SAME_AS_ABOVE: 'same as above',
SHOWING_OF_TOTAL: '{{visible}} of {{total}}',
},
@@ -373,7 +374,26 @@ describe('VodSourcesMenuComponent', () => {
).toBe(1);
});
it('builds the language select from parsed title prefixes and filters by it', () => {
function languageTrigger(): HTMLButtonElement {
return fixture.debugElement.query(By.css('.sources-menu__lang'))
.nativeElement;
}
/** Opens the language menu; its rows render in the overlay. */
function openLanguageMenu(): HTMLButtonElement[] {
languageTrigger().click();
fixture.detectChanges();
return Array.from(
document.querySelectorAll<HTMLButtonElement>(
'.sources-language-menu [role="menuitemradio"]'
)
);
}
const rowText = (row: HTMLElement) =>
(row.textContent ?? '').replace('check', '').trim();
it('offers the parsed title prefixes in a radio menu and filters by the chosen one', () => {
render([
createSource({
id: 'a:xtream:1',
@@ -389,18 +409,35 @@ describe('VodSourcesMenuComponent', () => {
}),
]);
const select = fixture.debugElement.query(
By.css('.sources-menu__lang')
// No native select: a chip trigger named with what it picks.
expect(fixture.nativeElement.querySelector('select')).toBeNull();
const trigger = languageTrigger();
expect(trigger.getAttribute('aria-label')).toBe(
'Language: All languages'
);
const options = select.nativeElement
.textContent as string;
expect(options).toContain('EN');
expect(options).toContain('RU');
expect(trigger.classList).not.toContain('filter-chip--on');
fixture.componentInstance.setLanguageFilter('RU');
let rows = openLanguageMenu();
expect(rows.map(rowText)).toEqual(['All languages', 'EN', 'RU']);
expect(rows.map((row) => row.getAttribute('aria-checked'))).toEqual(
['true', 'false', 'false']
);
rows[2].click();
fixture.detectChanges();
expect(rowNames()).toEqual(['Russian Portal']);
expect(trigger.getAttribute('aria-label')).toBe('Language: RU');
expect(trigger.textContent).toContain('RU');
expect(trigger.classList).toContain('filter-chip--on');
rows = openLanguageMenu();
expect(rows.map((row) => row.getAttribute('aria-checked'))).toEqual(
['false', 'false', 'true']
);
rows[0].click();
fixture.detectChanges();
expect(rowNames()).toEqual(['English Portal', 'Russian Portal']);
});
it('offers a category-derived language when the title has no prefix', () => {
@@ -420,12 +457,10 @@ describe('VodSourcesMenuComponent', () => {
}),
]);
const select = fixture.debugElement.query(
By.css('.sources-menu__lang')
);
expect(select.nativeElement.textContent).toContain('DE');
const rows = openLanguageMenu();
expect(rows.map(rowText)).toEqual(['All languages', 'DE']);
fixture.componentInstance.setLanguageFilter('DE');
rows[1].click();
fixture.detectChanges();
expect(rowNames()).toEqual(['German Portal']);
@@ -8,11 +8,16 @@ import {
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatIcon } from '@angular/material/icon';
import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu';
import {
MatSlideToggle,
MatSlideToggleChange,
} from '@angular/material/slide-toggle';
import { TranslatePipe } from '@ngx-translate/core';
import {
MenuItemRadioCheckDirective,
MenuItemRadioDirective,
} from '../menu-item-radio/menu-item-radio.directive';
import {
foldSearchText,
playlistDisplayLabel,
@@ -52,7 +57,12 @@ const FILTER_FROM = 8;
imports: [
FormsModule,
MatIcon,
MatMenu,
MatMenuItem,
MatMenuTrigger,
MatSlideToggle,
MenuItemRadioCheckDirective,
MenuItemRadioDirective,
TranslatePipe,
VodSourceCopyRowComponent,
VodSourceRowComponent,
+114 -23
View File
@@ -281,38 +281,106 @@ $_sizes: (
}
}
/// `mat-button-toggle-group` as a segmented control: a recessed track, no
/// dividers, the chosen segment filled.
@mixin segmented-control($height: 30px) {
// The segmented control: a recessed track, no dividers, the chosen segment
// filled. `segmented-control` dresses a `mat-button-toggle-group`;
// `segmented-track` and `segment` build the same look from plain buttons
// (the season tabs, which are a tab list). An unchosen segment is text in the
// body colour: the muted colour reads about 2.9:1 on the track, under the
// 4.5:1 its 12.5px label needs.
$_segment-text: var(--app-body-color);
$_segment-selected-text: var(--app-heading-color);
$_segment-selected-fill: color-mix(
in srgb,
var(--app-on-surface) 10%,
transparent
);
$_segment-hover-fill: color-mix(in srgb, var(--app-on-surface) 6%, transparent);
$_segment-text-size: 12.5px;
/// The track: 2px padding inside a 1px border, so it adds 6px to the
/// segments' height (26px segments give a 32px control, a small button's
/// height).
@mixin segmented-track {
display: inline-flex;
// Never wider than its container: when the segments do not fit (four
// translated season names in the fullscreen episode panel, a narrow
// window), they wrap inside the track instead of being clipped.
flex-wrap: wrap;
box-sizing: border-box;
max-width: 100%;
gap: 1px;
padding: 2px;
border: 1px solid var(--app-line-strong);
border-radius: $radius;
background: var(--app-content-bg);
}
/// A plain-button segment of `segmented-track`. Mark the chosen one with
/// `segment-selected`.
@mixin segment($height: 26px) {
display: inline-flex;
align-items: center;
gap: 6px;
height: $height;
padding: 0 10px;
border: 0;
border-radius: $radius-sm;
background: transparent;
color: $_segment-text;
font: inherit;
font-size: $_segment-text-size;
font-weight: 500;
cursor: pointer;
&:hover {
background: $_segment-hover-fill;
}
}
/// Hovering the chosen segment keeps its fill.
@mixin segment-selected {
&,
&:hover {
background: $_segment-selected-fill;
color: $_segment-selected-text;
}
}
/// `mat-button-toggle-group` as the segmented control. `$height` is the
/// segment's height (see `segmented-track`). `$icon-size` sizes a segment's
/// `mat-icon`; `$segment-width` gives icon-only segments one width (the
/// list/grid switch is 30×26).
///
/// Include it from a component stylesheet. Material renders a segment's
/// label and check without the including component's encapsulation
/// attribute, so those rules go through `::ng-deep`, scoped by the selector
/// that includes the mixin.
@mixin segmented-control(
$height: 30px,
$icon-size: 14px,
$segment-width: null
) {
@include mat.button-toggle-overrides(
(
height: $height,
shape: 10px,
shape: $radius,
divider-color: transparent,
background-color: transparent,
text-color: var(--app-muted-color),
selected-state-background-color: color-mix(
in srgb,
var(--app-on-surface) 10%,
transparent
),
selected-state-text-color: var(--app-heading-color),
text-color: $_segment-text,
selected-state-background-color: $_segment-selected-fill,
selected-state-text-color: $_segment-selected-text,
state-layer-color: var(--app-on-surface),
hover-state-layer-opacity: 0.06,
focus-state-layer-opacity: 0.06,
label-text-size: 12.5px,
label-text-size: $_segment-text-size,
label-text-weight: 500,
label-text-tracking: 0,
)
);
display: inline-flex;
gap: 1px;
padding: 2px;
border: 1px solid var(--app-line-strong);
background: var(--app-content-bg);
@include segmented-track;
.mat-button-toggle {
border-radius: 8px;
border-radius: $radius-sm;
border: 0;
}
@@ -322,11 +390,34 @@ $_sizes: (
border-inline-start: 0;
}
.mat-button-toggle-label-content {
padding: 0 10px;
}
@include _icon-size($icon-size);
.mat-pseudo-checkbox {
display: none;
::ng-deep {
// A flex row centres an icon beside the label. Its height is set
// here because an icon-only segment has no text line to give it one,
// and top alignment keeps an icon's baseline from adding a pixel.
.mat-button-toggle-label-content {
display: inline-flex;
align-items: center;
gap: 6px;
height: $height;
padding: 0 10px;
vertical-align: top;
}
.mat-pseudo-checkbox {
display: none;
}
@if $segment-width {
.mat-button-toggle-button {
width: $segment-width;
}
.mat-button-toggle-label-content {
justify-content: center;
padding: 0;
}
}
}
}