feat(ui): shared cinematic detail pieces (chip, light primary, icon button, more menu, cast row, trailer dialog)

Adds the building blocks of the redesigned VOD details and dashboard hero:
a pill meta chip, the light primary button with a two-line label, a 44px
ghost icon button with tooltip, the right-aligned "…" dropdown menu that
flips upward and hosts the alternative-sources panel, hero credits plus a
Cast & crew row, a horizontal rail with prev/next arrows, a Similar rail
and the trailer modal. Shared detail utilities (season suffix, remaining
time, duration, short country names) move to portal/shared/util, with the
new CTA and rating theme tokens and the i18n keys for all locales.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-10-02 07:58:06 +02:00
1 parent b6357da6af
commit 3fd65c935c
63 files changed
+3025 -92

No files matched your search

+13
View File
@@ -33,3 +33,16 @@ export * from './lib/window-controls/window-controls.component';
export * from './lib/channel-scroll-focus/channel-scroll-focus.directive';
export * from './lib/category-lock-menu/category-lock-menu.component';
export * from './lib/detail-ui/cast-crew.util';
export * from './lib/detail-ui/cast-crew-row.component';
export * from './lib/detail-ui/detail-credits.component';
export * from './lib/detail-ui/detail-icon-button.component';
export * from './lib/detail-ui/detail-primary-button.component';
export * from './lib/detail-ui/detail-rail.component';
export * from './lib/detail-ui/meta-chip.component';
export * from './lib/detail-ui/similar-rail.component';
export * from './lib/detail-ui/trailer-dialog.component';
export * from './lib/detail-ui/trailer-dialog.service';
export * from './lib/detail-ui/vod-more-menu.component';
export * from './lib/detail-ui/vod-more-menu.model';
@@ -0,0 +1,37 @@
@if (entries().length) {
<app-detail-rail
[title]="'PORTALS.DETAIL.CAST_AND_CREW' | translate"
[count]="entries().length"
[headingId]="headingId()"
data-test-id="detail-cast-crew"
>
@for (entry of entries(); track entry.key) {
<div
class="person"
[class.person--clickable]="isClickable(entry)"
[attr.role]="isClickable(entry) ? 'button' : null"
[attr.tabindex]="isClickable(entry) ? 0 : null"
(click)="select(entry)"
(keydown.enter)="select(entry)"
(keydown.space)="$event.preventDefault(); select(entry)"
>
@if (entry.profileUrl; as photo) {
<img
class="person__avatar"
[src]="photo"
[alt]="entry.name"
loading="lazy"
/>
} @else {
<span
class="person__avatar person__avatar--initials"
aria-hidden="true"
>{{ entry.initials }}</span
>
}
<p class="person__name">{{ entry.name }}</p>
<small class="person__role">{{ entry.role ?? ' ' }}</small>
</div>
}
</app-detail-rail>
}
@@ -0,0 +1,66 @@
:host {
display: block;
}
.person {
flex: 0 0 96px;
text-align: center;
border-radius: 8px;
&--clickable {
cursor: pointer;
&:hover .person__avatar {
border-color: color-mix(
in srgb,
var(--app-on-surface, #e6e1e5) 50%,
transparent
);
}
&:focus-visible {
outline: 2px solid var(--app-selection-color, #78adff);
outline-offset: 2px;
}
}
}
.person__avatar {
display: block;
box-sizing: border-box;
width: 72px;
height: 72px;
margin: 0 auto;
border: 1px solid var(--app-separator, rgba(255, 255, 255, 0.1));
border-radius: 50%;
object-fit: cover;
background: var(--app-widget-bg, #1d2533);
transition: border-color 0.15s ease;
&--initials {
display: grid;
place-items: center;
color: var(--app-body-color, #9aa3b2);
font-size: 18px;
font-weight: 600;
}
}
.person__name {
margin: 8px 0 2px;
color: var(--app-heading-color, #e7ecf3);
font-size: 12.5px;
line-height: 1.3;
text-wrap: balance;
overflow-wrap: anywhere;
}
.person__role {
display: block;
color: var(--app-eyebrow-color, #6b7384);
font-size: 11.5px;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -0,0 +1,53 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
output,
} from '@angular/core';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import type { TmdbEnrichedCastMember } from '@iptvnator/shared/interfaces';
import { buildCastCrewEntries, type CastCrewEntry } from './cast-crew.util';
import { DetailRailComponent } from './detail-rail.component';
/**
* "Cast & crew" row under the hero: 72px round avatars with the full name
* and the character (or "Director"), the director first, initials when a
* person has no photo. A click on a person with a TMDB id opens the actor
* page through the host.
*/
@Component({
selector: 'app-cast-crew-row',
imports: [DetailRailComponent, TranslatePipe],
templateUrl: './cast-crew-row.component.html',
styleUrl: './cast-crew-row.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CastCrewRowComponent {
readonly cast = input<readonly TmdbEnrichedCastMember[]>([]);
readonly directors = input<readonly TmdbEnrichedCastMember[]>([]);
readonly headingId = input<string | null>('detail-cast-crew');
readonly personSelected = output<TmdbEnrichedCastMember>();
private readonly translate = inject(TranslateService);
readonly entries = computed<CastCrewEntry[]>(() =>
buildCastCrewEntries(
this.cast(),
this.directors(),
this.translate.instant('XTREAM.DIRECTOR')
)
);
isClickable(entry: CastCrewEntry): boolean {
return !!entry.member.tmdbPersonId;
}
select(entry: CastCrewEntry): void {
if (this.isClickable(entry)) {
this.personSelected.emit(entry.member);
}
}
}
@@ -0,0 +1,51 @@
import {
buildCastCrewEntries,
castMembersFromNames,
personInitials,
splitPeopleNames,
} from './cast-crew.util';
describe('splitPeopleNames', () => {
it('splits on commas and semicolons and drops duplicates', () => {
expect(splitPeopleNames('Mara Venn, Elias Shore; mara venn, ')).toEqual([
'Mara Venn',
'Elias Shore',
]);
expect(splitPeopleNames(undefined)).toEqual([]);
});
});
describe('buildCastCrewEntries', () => {
it('lists the director first and dedupes a director who also acts', () => {
const entries = buildCastCrewEntries(
[
{ name: 'Russell Crowe', character: 'Henry Murray', profileUrl: 'a.jpg', tmdbPersonId: 1 },
{ name: 'Janus Metz', profileUrl: null },
{ name: 'Mark Camacho', profileUrl: null },
],
[{ name: 'Janus Metz', profileUrl: null, tmdbPersonId: 7 }],
'Director'
);
expect(entries.map((entry) => [entry.name, entry.role])).toEqual([
['Janus Metz', 'Director'],
['Russell Crowe', 'Henry Murray'],
['Mark Camacho', null],
]);
expect(entries[0].key).toBe('p7');
expect(entries[2].initials).toBe('MC');
});
it('wraps provider names without photos', () => {
expect(castMembersFromNames(['A B'])).toEqual([
{ name: 'A B', profileUrl: null },
]);
});
});
describe('personInitials', () => {
it('takes the first and last initials', () => {
expect(personInitials('Aleksandr Samoylenko Jr.')).toBe('AJ');
expect(personInitials('Cher')).toBe('C');
expect(personInitials('')).toBe('');
});
});
@@ -0,0 +1,90 @@
import type { TmdbEnrichedCastMember } from '@iptvnator/shared/interfaces';
/** One avatar of the "Cast & crew" row. */
export interface CastCrewEntry {
readonly key: string;
readonly name: string;
/** Character for actors, a translated "Director" for crew, else null. */
readonly role: string | null;
readonly profileUrl: string | null;
readonly initials: string;
readonly member: TmdbEnrichedCastMember;
}
/**
* "Mara Venn, Elias Shore; Tomas Bell" → names. Providers join the cast with
* commas (Xtream) or semicolons (some Stalker portals); either way a name
* never contains the separator.
*/
export function splitPeopleNames(value: string | null | undefined): string[] {
if (!value) {
return [];
}
const seen = new Set<string>();
const names: string[] = [];
for (const part of value.split(/[,;]/)) {
const name = part.trim();
const key = normalizePersonName(name);
if (!key || seen.has(key)) {
continue;
}
seen.add(key);
names.push(name);
}
return names;
}
/** Plain names (no TMDB data) as cast members without photo or character. */
export function castMembersFromNames(
names: readonly string[]
): TmdbEnrichedCastMember[] {
return names.map((name) => ({ name, profileUrl: null }));
}
/**
* Director first, then the cast, one entry per person: a director who also
* acts keeps the single "Director" avatar.
*/
export function buildCastCrewEntries(
cast: readonly TmdbEnrichedCastMember[],
directors: readonly TmdbEnrichedCastMember[],
directorRole: string
): CastCrewEntry[] {
const seen = new Set<string>();
const entries: CastCrewEntry[] = [];
const push = (member: TmdbEnrichedCastMember, role: string | null) => {
const key = normalizePersonName(member.name);
if (!key || seen.has(key)) {
return;
}
seen.add(key);
entries.push({
key: member.tmdbPersonId ? `p${member.tmdbPersonId}` : `n:${key}`,
name: member.name.trim(),
role,
profileUrl: member.profileUrl ?? null,
initials: personInitials(member.name),
member,
});
};
directors.forEach((member) => push(member, directorRole));
cast.forEach((member) => push(member, member.character?.trim() || null));
return entries;
}
export function personInitials(name: string): string {
const parts = name
.trim()
.split(/\s+/)
.filter((part) => part.length > 0);
if (parts.length === 0) {
return '';
}
const first = parts[0][0] ?? '';
const last = parts.length > 1 ? (parts[parts.length - 1][0] ?? '') : '';
return `${first}${last}`.toUpperCase();
}
function normalizePersonName(name: string): string {
return name.trim().toLowerCase().replace(/\s+/g, ' ');
}
@@ -0,0 +1,23 @@
@if (cast().length) {
<div class="credits__row" data-test-id="detail-credits-cast">
<span class="credits__label">{{
'PORTALS.DETAIL.STARRING' | translate
}}</span>
<span class="credits__names">{{ leadCast() }}</span>
@if (hasMore()) {
<button
type="button"
class="credits__more"
(click)="moreRequested.emit()"
>
{{ 'PORTALS.DETAIL.AND_MORE' | translate }}
</button>
}
</div>
}
@if (directors().length) {
<div class="credits__row" data-test-id="detail-credits-director">
<span class="credits__label">{{ 'XTREAM.DIRECTOR' | translate }}</span>
<span class="credits__names">{{ directorNames() }}</span>
</div>
}
@@ -0,0 +1,46 @@
:host {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
line-height: 1.5;
color: var(--app-heading-color, #e7ecf3);
}
.credits__row {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0 8px;
}
.credits__label {
flex: 0 0 auto;
min-width: 64px;
color: var(--app-eyebrow-color, #6b7384);
font-weight: 500;
}
.credits__names {
min-width: 0;
}
.credits__more {
padding: 0;
border: 0;
background: none;
color: var(--app-body-color, #9aa3b2);
font: inherit;
cursor: pointer;
&:hover {
color: var(--app-heading-color, #e7ecf3);
text-decoration: underline;
}
&:focus-visible {
outline: 2px solid var(--app-selection-color, #78adff);
outline-offset: 2px;
border-radius: 2px;
}
}
@@ -0,0 +1,49 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { DetailCreditsComponent } from './detail-credits.component';
describe('DetailCreditsComponent', () => {
let fixture: ComponentFixture<DetailCreditsComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [DetailCreditsComponent, TranslateModule.forRoot()],
}).compileComponents();
const translate = TestBed.inject(TranslateService);
translate.setTranslation('en', {
PORTALS: { DETAIL: { STARRING: 'Starring', AND_MORE: 'and more' } },
XTREAM: { DIRECTOR: 'Director' },
});
translate.use('en');
fixture = TestBed.createComponent(DetailCreditsComponent);
});
it('shows three lead names, an "and more" link and the director', () => {
fixture.componentRef.setInput('cast', ['A', 'B', 'C', 'D']);
fixture.componentRef.setInput('directors', ['Janus Metz']);
fixture.detectChanges();
const host = fixture.nativeElement as HTMLElement;
const castLine = host.querySelector(
'[data-test-id="detail-credits-cast"]'
)?.textContent;
expect(castLine).toContain('A, B, C');
expect(castLine).not.toContain('A, B, C, D');
const more = host.querySelector<HTMLButtonElement>('.credits__more')!;
const spy = jest.fn();
fixture.componentInstance.moreRequested.subscribe(spy);
more.click();
expect(spy).toHaveBeenCalled();
expect(
host.querySelector('[data-test-id="detail-credits-director"]')
?.textContent
).toContain('Janus Metz');
});
it('hides the link when three or fewer names exist and renders nothing when empty', () => {
fixture.componentRef.setInput('cast', ['A', 'B']);
fixture.detectChanges();
const host = fixture.nativeElement as HTMLElement;
expect(host.querySelector('.credits__more')).toBeNull();
expect(host.querySelector('[data-test-id="detail-credits-director"]')).toBeNull();
});
});
@@ -0,0 +1,36 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
} from '@angular/core';
import { TranslatePipe } from '@ngx-translate/core';
const LEAD_CAST_LIMIT = 3;
/**
* Two text lines of credits inside the details hero: "Starring" with the
* first three names and an "and more" link to the Cast & crew row, then
* "Director". Nothing renders for an empty list.
*/
@Component({
selector: 'app-detail-credits',
imports: [TranslatePipe],
templateUrl: './detail-credits.component.html',
styleUrl: './detail-credits.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DetailCreditsComponent {
readonly cast = input<readonly string[]>([]);
readonly directors = input<readonly string[]>([]);
/** "and more" was activated: the host scrolls to the Cast & crew row. */
readonly moreRequested = output<void>();
readonly leadCast = computed(() =>
this.cast().slice(0, LEAD_CAST_LIMIT).join(', ')
);
readonly hasMore = computed(() => this.cast().length > LEAD_CAST_LIMIT);
readonly directorNames = computed(() => this.directors().join(', '));
}
@@ -0,0 +1,20 @@
<button
type="button"
class="icon-btn"
[class.is-active]="active()"
[class.is-pulsing]="pulsing()"
[class.icon-btn--favorite]="tone() === 'favorite'"
[class.icon-btn--watched]="tone() === 'watched'"
[class.icon-btn--done]="tone() === 'done'"
[disabled]="disabled()"
[matTooltip]="label()"
[attr.aria-label]="label()"
[attr.aria-pressed]="pressedState()"
[attr.data-testid]="testId()"
(click)="pressed.emit()"
>
<ng-content select="[glyph]" />
@if (icon(); as glyph) {
<mat-icon aria-hidden="true">{{ glyph }}</mat-icon>
}
</button>
@@ -0,0 +1,59 @@
@use '../../../../styles/detail-view-actions' as actions;
:host {
display: inline-flex;
}
.icon-btn {
@include actions.ghost-action-surface;
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
box-sizing: border-box;
width: 44px;
height: 44px;
padding: 0;
border-radius: 10px;
cursor: pointer;
mat-icon {
width: 20px;
height: 20px;
font-size: 20px;
line-height: 20px;
}
&:disabled {
cursor: default;
opacity: 0.55;
}
// Favorited: the glyph fills and turns red, the surface stays a ghost.
&--favorite.is-active {
color: var(--app-live-color, #ff5b6e);
}
&--favorite.is-pulsing mat-icon {
animation: icon-btn-pulse 180ms ease;
}
&--watched.is-active,
&--done {
color: #4ade80;
}
}
@keyframes icon-btn-pulse {
50% {
transform: scale(1.15);
}
}
@media (prefers-reduced-motion: reduce) {
.icon-btn mat-icon {
animation: none !important;
}
}
@@ -0,0 +1,32 @@
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
/** Which accent an active icon button takes. */
export type DetailIconButtonTone = 'favorite' | 'watched' | 'done' | 'none';
/**
* 44px ghost icon button of the details action row (Favorites, Mark as
* watched, Download, More). The label is both the tooltip and the accessible
* name. A projected `[glyph]` (the download progress ring) replaces the icon.
*/
@Component({
selector: 'app-detail-icon-button',
imports: [MatIcon, MatTooltip],
templateUrl: './detail-icon-button.component.html',
styleUrl: './detail-icon-button.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DetailIconButtonComponent {
readonly label = input.required<string>();
readonly icon = input<string | null>(null);
readonly active = input(false);
readonly tone = input<DetailIconButtonTone>('none');
/** Set for toggles; `null` leaves `aria-pressed` off a plain action. */
readonly pressedState = input<boolean | null>(null);
readonly disabled = input(false);
readonly pulsing = input(false);
readonly testId = input<string | null>(null);
readonly pressed = output<void>();
}
@@ -0,0 +1,19 @@
<button
type="button"
class="play-btn"
[class.play-btn--launching]="state() === 'launching'"
[class.play-btn--stop]="state() === 'stop'"
[class.play-btn--two-line]="!!meta()"
[disabled]="disabled()"
[attr.data-testid]="testId()"
[attr.aria-busy]="state() === 'launching' ? 'true' : null"
(click)="pressed.emit()"
>
<mat-icon class="play-btn__icon" aria-hidden="true">{{ icon() }}</mat-icon>
<span class="play-btn__copy">
<span class="play-btn__label">{{ label() }}</span>
@if (meta(); as metaLine) {
<span class="play-btn__meta">{{ metaLine }}</span>
}
</span>
</button>
@@ -0,0 +1,98 @@
@use '../../../../styles/detail-view-actions' as actions;
:host {
display: inline-flex;
max-width: 100%;
}
.play-btn {
@include actions.light-primary-button;
height: 44px;
max-width: 100%;
&__copy {
display: inline-flex;
flex-direction: column;
align-items: flex-start;
min-width: 0;
text-align: left;
}
&__label {
display: block;
line-height: 1.15;
}
&__meta {
display: block;
max-width: min(42vw, 320px);
overflow: hidden;
margin-top: 1px;
color: var(--app-cta-meta-fg, #5a6274);
font-size: 11px;
font-weight: 500;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
// External player feedback: a warm tint while the player starts, a red
// one while the button stops it. Tints, not glows.
&--launching {
background: #ffd36f;
color: #241103;
.play-btn__meta {
color: rgba(36, 17, 3, 0.7);
}
.play-btn__icon {
animation: play-btn-launch 1.15s cubic-bezier(0.55, 0, 0.45, 1)
infinite;
}
&:hover:not(:disabled) {
background: #ffdd8a;
}
}
&--stop {
background: #ff9f8d;
color: #321012;
.play-btn__meta {
color: rgba(50, 16, 18, 0.7);
}
&:hover:not(:disabled) {
background: #ffb3a5;
}
}
}
@keyframes play-btn-launch {
0%,
100% {
transform: rotate(0deg) scale(1);
}
50% {
transform: rotate(18deg) scale(1.1);
}
}
@media (max-width: 640px) {
.play-btn {
flex: 1;
justify-content: center;
}
}
@media (prefers-reduced-motion: reduce) {
.play-btn,
.play-btn__icon {
animation: none !important;
transition-duration: 1ms !important;
}
}
@@ -0,0 +1,29 @@
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
import { MatIcon } from '@angular/material/icon';
/** External-player launch feedback carried by the primary button. */
export type DetailPrimaryButtonState = 'idle' | 'launching' | 'stop';
/**
* The light primary call to action of a details page: play icon plus a
* two-line label ("Continue" / "S02E03 · 18m left"). The same surface as the
* dashboard hero's primary (`light-primary-button` mixin).
*/
@Component({
selector: 'app-detail-primary-button',
imports: [MatIcon],
templateUrl: './detail-primary-button.component.html',
styleUrl: './detail-primary-button.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DetailPrimaryButtonComponent {
readonly label = input.required<string>();
/** Second, smaller line: episode code, time left, duration. */
readonly meta = input<string | null>(null);
readonly icon = input('play_arrow');
readonly state = input<DetailPrimaryButtonState>('idle');
readonly disabled = input(false);
readonly testId = input<string | null>(null);
readonly pressed = output<void>();
}
@@ -0,0 +1,32 @@
<section class="rail">
<header class="rail__header">
<h3 class="rail__title" [attr.id]="headingId()">{{ title() }}</h3>
@if (count() !== null) {
<span class="rail__count">{{ count() }}</span>
}
<ng-content select="[rail-header]" />
@if (overflowing()) {
<div class="rail__arrows">
<button
type="button"
class="rail__arrow"
[attr.aria-label]="'PORTALS.DETAIL.SCROLL_PREVIOUS' | translate"
(click)="scrollBy(-1)"
>
<mat-icon aria-hidden="true">chevron_left</mat-icon>
</button>
<button
type="button"
class="rail__arrow"
[attr.aria-label]="'PORTALS.DETAIL.SCROLL_NEXT' | translate"
(click)="scrollBy(1)"
>
<mat-icon aria-hidden="true">chevron_right</mat-icon>
</button>
</div>
}
</header>
<div #scroller class="rail__scroller">
<ng-content />
</div>
</section>
@@ -0,0 +1,80 @@
:host {
display: block;
min-width: 0;
}
.rail__header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 14px;
}
.rail__title {
margin: 0;
color: var(--app-heading-color, #e7ecf3);
font-size: 16px;
font-weight: 600;
line-height: 1.3;
}
.rail__count {
color: var(--app-eyebrow-color, #6b7384);
font-size: 12px;
}
.rail__arrows {
display: flex;
gap: 6px;
margin-left: auto;
}
.rail__arrow {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
padding: 0;
border: 1px solid var(--app-separator, rgba(255, 255, 255, 0.1));
border-radius: 50%;
background: var(--app-widget-bg, #0f141c);
color: var(--app-heading-color, #e7ecf3);
cursor: pointer;
transition: background-color 0.15s ease;
&:hover {
background: var(--app-card-hover-bg, #1d2533);
}
&:focus-visible {
outline: 2px solid var(--app-selection-color, #78adff);
outline-offset: 2px;
}
mat-icon {
width: 18px;
height: 18px;
font-size: 18px;
line-height: 18px;
}
}
.rail__scroller {
display: flex;
gap: 14px;
overflow-x: auto;
padding-bottom: 6px;
scroll-behavior: smooth;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.rail__scroller {
scroll-behavior: auto;
}
}
@@ -0,0 +1,68 @@
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
ElementRef,
afterNextRender,
inject,
input,
signal,
viewChild,
} from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
const SCROLL_STEP_PX = 450;
/**
* A details-page row: heading, optional count, previous/next buttons and a
* horizontal scroller with a hidden scrollbar. Hosts project the cards.
* The arrows only render while the content overflows.
*/
@Component({
selector: 'app-detail-rail',
imports: [MatIcon, TranslatePipe],
templateUrl: './detail-rail.component.html',
styleUrl: './detail-rail.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DetailRailComponent {
readonly title = input.required<string>();
readonly count = input<number | string | null>(null);
readonly headingId = input<string | null>(null);
readonly overflowing = signal(false);
private readonly scroller =
viewChild.required<ElementRef<HTMLElement>>('scroller');
private readonly destroyRef = inject(DestroyRef);
constructor() {
afterNextRender(() => this.observeOverflow());
}
scrollBy(direction: -1 | 1): void {
this.scroller().nativeElement.scrollBy({
left: direction * SCROLL_STEP_PX,
behavior: 'smooth',
});
}
private observeOverflow(): void {
const element = this.scroller().nativeElement;
const measure = () =>
this.overflowing.set(element.scrollWidth > element.clientWidth + 1);
measure();
if (typeof ResizeObserver === 'undefined') {
return;
}
const observer = new ResizeObserver(measure);
observer.observe(element);
const mutations = new MutationObserver(measure);
mutations.observe(element, { childList: true, subtree: true });
this.destroyRef.onDestroy(() => {
observer.disconnect();
mutations.disconnect();
});
}
}
@@ -0,0 +1,56 @@
:host {
display: inline-flex;
align-items: center;
gap: 4px;
box-sizing: border-box;
max-width: 100%;
padding: 3px 9px;
border: 0;
border-radius: 999px;
background: color-mix(in srgb, var(--app-on-surface, #e6e1e5) 10%, transparent);
color: var(--app-heading-color, #d8dce8);
font-family: inherit;
font-size: 12px;
font-weight: 500;
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
:host(.meta-chip--rating) {
color: var(--app-rating-color, #ffcf5c);
font-weight: 600;
}
// Production status is a state, not a fact about the title: the soft
// selection tint sets it apart from the plain facts beside it.
:host(.meta-chip--status) {
background: var(--app-selection-surface, rgba(152, 184, 255, 0.16));
color: var(--app-selection-color, #98b8ff);
}
// Facets inside one chip ("Thriller · Drama"): each genre stays a button.
:host ::ng-deep .meta-chip__facet {
appearance: none;
padding: 0;
border: 0;
background: none;
color: inherit;
font: inherit;
cursor: pointer;
&:hover {
text-decoration: underline;
}
&:focus-visible {
outline: 2px solid var(--app-selection-color, #78adff);
outline-offset: 1px;
border-radius: 2px;
}
}
:host ::ng-deep .meta-chip__sep {
opacity: 0.55;
}
@@ -0,0 +1,36 @@
import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { MetaChipComponent } from './meta-chip.component';
@Component({
imports: [MetaChipComponent],
template: `
<app-meta-chip data-test-id="plain">2026</app-meta-chip>
<app-meta-chip variant="rating" data-test-id="rating">★ 7.3</app-meta-chip>
<app-meta-chip variant="status" data-test-id="status">
<button type="button" class="meta-chip__facet">Returning</button>
</app-meta-chip>
`,
})
class HostComponent {}
describe('MetaChipComponent', () => {
it('applies the variant classes on the host element', async () => {
await TestBed.configureTestingModule({
imports: [HostComponent],
}).compileComponents();
const fixture = TestBed.createComponent(HostComponent);
fixture.detectChanges();
const host = fixture.nativeElement as HTMLElement;
const plain = host.querySelector('[data-test-id="plain"]')!;
const rating = host.querySelector('[data-test-id="rating"]')!;
const status = host.querySelector('[data-test-id="status"]')!;
expect(plain.classList.contains('meta-chip')).toBe(true);
expect(plain.classList.contains('meta-chip--rating')).toBe(false);
expect(rating.classList.contains('meta-chip--rating')).toBe(true);
expect(status.classList.contains('meta-chip--status')).toBe(true);
expect(status.querySelector('button.meta-chip__facet')?.textContent?.trim()).toBe(
'Returning'
);
});
});
@@ -0,0 +1,26 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
export type MetaChipVariant = 'default' | 'rating' | 'status';
/**
* Pill meta chip shared by the VOD details hero and the dashboard hero
* ("2026", "1 h 52 min", "★ 7.3", "Season 2", "Returning").
*
* A facet that opens the discover page keeps its keyboard behaviour as a
* projected `<button class="meta-chip__facet">`; several facets in one chip
* ("Thriller · Drama") are separated by `.meta-chip__sep`.
*/
@Component({
selector: 'app-meta-chip',
template: '<ng-content />',
styleUrl: './meta-chip.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'meta-chip',
'[class.meta-chip--rating]': 'variant() === "rating"',
'[class.meta-chip--status]': 'variant() === "status"',
},
})
export class MetaChipComponent {
readonly variant = input<MetaChipVariant>('default');
}
@@ -0,0 +1,32 @@
@if (items().length) {
<app-detail-rail [title]="title()" data-test-id="tmdb-similar">
@for (item of items(); track item.key) {
<button
type="button"
class="similar-card"
[title]="item.tooltip ?? item.title"
(click)="selected.emit(item)"
>
@if (posterFor(item); as poster) {
<img
class="similar-card__poster"
[src]="poster"
[alt]="item.title"
loading="lazy"
(error)="markFailed(poster)"
/>
} @else {
<span
class="similar-card__poster similar-card__poster--fallback"
aria-hidden="true"
>{{ item.title.charAt(0) }}</span
>
}
<span class="similar-card__title">{{ item.title }}</span>
@if (item.year) {
<span class="similar-card__year">{{ item.year }}</span>
}
</button>
}
</app-detail-rail>
}
@@ -0,0 +1,68 @@
:host {
display: block;
}
.similar-card {
display: flex;
flex: 0 0 140px;
flex-direction: column;
gap: 2px;
width: 140px;
padding: 0;
border: 0;
border-radius: 10px;
background: none;
color: inherit;
font-family: inherit;
text-align: left;
cursor: pointer;
&:hover .similar-card__poster {
transform: scale(1.03);
}
&:focus-visible {
outline: 2px solid var(--app-selection-color, #78adff);
outline-offset: 2px;
}
}
.similar-card__poster {
display: block;
width: 140px;
aspect-ratio: 2 / 3;
margin-bottom: 6px;
border-radius: 10px;
object-fit: cover;
background: color-mix(in srgb, var(--app-on-surface, #e6e1e5) 8%, transparent);
transition: transform 0.15s ease;
&--fallback {
display: grid;
place-items: center;
color: var(--app-eyebrow-color, #6b7384);
font-size: 2rem;
font-weight: 600;
text-transform: uppercase;
}
}
.similar-card__title {
overflow: hidden;
color: var(--app-heading-color, #e7ecf3);
font-size: 13px;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.similar-card__year {
color: var(--app-eyebrow-color, #6b7384);
font-size: 11.5px;
}
@media (prefers-reduced-motion: reduce) {
.similar-card__poster {
transition: none;
}
}
@@ -0,0 +1,42 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { SimilarRailComponent } from './similar-rail.component';
describe('SimilarRailComponent', () => {
let fixture: ComponentFixture<SimilarRailComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SimilarRailComponent, TranslateModule.forRoot()],
}).compileComponents();
fixture = TestBed.createComponent(SimilarRailComponent);
fixture.componentRef.setInput('title', 'Similar');
fixture.componentRef.setInput('items', [
{ key: 'a', title: 'Ted K', posterUrl: 'ted.jpg', year: 2021 },
{ key: 'b', title: 'Zodiac', posterUrl: null, year: null, tooltip: 'Backup' },
]);
fixture.detectChanges();
});
it('renders the year, no source line, and a letter fallback', () => {
const host = fixture.nativeElement as HTMLElement;
const cards = host.querySelectorAll('.similar-card');
expect(cards).toHaveLength(2);
expect(cards[0].textContent).toContain('2021');
expect(cards[1].querySelector('.similar-card__poster--fallback')?.textContent?.trim()).toBe('Z');
expect(cards[1].getAttribute('title')).toBe('Backup');
expect(host.textContent).not.toContain('http');
});
it('emits the item on click and swaps a broken poster for the fallback', () => {
const host = fixture.nativeElement as HTMLElement;
const spy = jest.fn();
fixture.componentInstance.selected.subscribe(spy);
host.querySelector<HTMLButtonElement>('.similar-card')!.click();
expect(spy).toHaveBeenCalledWith(expect.objectContaining({ key: 'a' }));
host.querySelector<HTMLImageElement>('img.similar-card__poster')!.dispatchEvent(new Event('error'));
fixture.detectChanges();
expect(host.querySelectorAll('.similar-card__poster--fallback')).toHaveLength(2);
});
});
@@ -0,0 +1,47 @@
import {
ChangeDetectionStrategy,
Component,
input,
output,
signal,
} from '@angular/core';
import { DetailRailComponent } from './detail-rail.component';
/** One poster of the "Similar" row. */
export interface SimilarRailItem {
readonly key: string;
readonly title: string;
readonly posterUrl: string | null;
readonly year: number | null;
/** Tooltip detail such as the playlist a cross-portal match lives in. */
readonly tooltip?: string | null;
}
/**
* "Similar" row: 140px posters with the title and year. Hosts map their
* TMDB and cross-portal matches to `SimilarRailItem` and react to clicks.
*/
@Component({
selector: 'app-similar-rail',
imports: [DetailRailComponent],
templateUrl: './similar-rail.component.html',
styleUrl: './similar-rail.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SimilarRailComponent {
readonly title = input.required<string>();
readonly items = input.required<readonly SimilarRailItem[]>();
readonly selected = output<SimilarRailItem>();
readonly failedPosters = signal<Record<string, true>>({});
posterFor(item: SimilarRailItem): string | null {
const url = item.posterUrl;
return url && !this.failedPosters()[url] ? url : null;
}
markFailed(url: string): void {
this.failedPosters.update((failed) => ({ ...failed, [url]: true }));
}
}
@@ -0,0 +1,120 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { DomSanitizer, type SafeResourceUrl } from '@angular/platform-browser';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { TranslatePipe } from '@ngx-translate/core';
export interface TrailerDialogData {
/** A `youtube-nocookie.com/embed/...` URL, see `youtubeEmbedUrl`. */
readonly embedUrl: string;
readonly title: string;
}
/**
* The trailer, as a 16:9 YouTube embed in a modal over the details column.
* Escape and the backdrop close it; the title row keeps the single Close.
*/
@Component({
selector: 'app-trailer-dialog',
imports: [MatDialogModule, MatButtonModule, TranslatePipe],
template: `
<div class="trailer">
<div class="trailer__frame">
<iframe
class="trailer__iframe"
[src]="embedUrl"
[title]="data.title"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerpolicy="strict-origin-when-cross-origin"
allowfullscreen
></iframe>
</div>
<div class="trailer__bar">
<h2 mat-dialog-title class="trailer__title">
{{ 'PORTALS.DETAIL.TRAILER_OF' | translate: { title: data.title } }}
</h2>
<button
mat-flat-button
type="button"
class="trailer__close"
mat-dialog-close
data-test-id="trailer-dialog-close"
>
{{ 'CLOSE' | translate }}
</button>
</div>
</div>
`,
styles: `
:host {
display: block;
}
.trailer {
display: flex;
flex-direction: column;
}
.trailer__frame {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
background: #000;
}
.trailer__iframe {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: 0;
}
.trailer__bar {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
}
// Keeps the directive (the dialog's accessible name) but drops
// Material's headline padding and the 40px strut before it.
.trailer__title {
flex: 1 1 auto;
min-width: 0;
margin: 0;
padding: 0;
overflow: hidden;
font-size: 14px;
font-weight: 600;
line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
&::before {
display: none;
}
}
.trailer__close {
flex: 0 0 auto;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TrailerDialogComponent {
readonly data = inject<TrailerDialogData>(MAT_DIALOG_DATA);
readonly embedUrl: SafeResourceUrl = inject(
DomSanitizer
).bypassSecurityTrustResourceUrl(withAutoplay(this.data.embedUrl));
}
function withAutoplay(embedUrl: string): string {
try {
const url = new URL(embedUrl);
url.searchParams.set('autoplay', '1');
return url.toString();
} catch {
return embedUrl;
}
}
@@ -0,0 +1,24 @@
import { inject, Injectable } from '@angular/core';
import { MatDialog, type MatDialogRef } from '@angular/material/dialog';
import {
TrailerDialogComponent,
type TrailerDialogData,
} from './trailer-dialog.component';
export const TRAILER_DIALOG_PANEL_CLASS = 'app-trailer-dialog-panel';
/** Opens the trailer modal at one width for every details page. */
@Injectable({ providedIn: 'root' })
export class TrailerDialogService {
private readonly dialog = inject(MatDialog);
open(data: TrailerDialogData): MatDialogRef<TrailerDialogComponent> {
return this.dialog.open(TrailerDialogComponent, {
data,
width: 'min(880px, calc(100vw - 48px))',
maxWidth: '880px',
autoFocus: 'dialog',
panelClass: TRAILER_DIALOG_PANEL_CLASS,
});
}
}
@@ -0,0 +1,117 @@
<button
#triggerButton
type="button"
class="more-trigger"
cdkOverlayOrigin
#trigger="cdkOverlayOrigin"
aria-haspopup="menu"
[class.more-trigger--open]="isOpen()"
[attr.aria-expanded]="isOpen()"
[attr.aria-label]="label()"
[attr.data-testid]="testId()"
[matTooltip]="label()"
[disabled]="!hasRows()"
(click)="toggle()"
(keydown)="onTriggerKeydown($event)"
>
<mat-icon aria-hidden="true">more_horiz</mat-icon>
</button>
<ng-template
cdkConnectedOverlay
[cdkConnectedOverlayOrigin]="trigger"
[cdkConnectedOverlayOpen]="isOpen()"
[cdkConnectedOverlayHasBackdrop]="true"
cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop"
[cdkConnectedOverlayPositions]="overlayPositions"
[cdkConnectedOverlayViewportMargin]="12"
[cdkConnectedOverlayFlexibleDimensions]="false"
(backdropClick)="close()"
(detach)="close()"
(overlayKeydown)="onOverlayKeydown($event)"
>
<div
#panel
class="more-menu"
[class.more-menu--sources]="view() === 'sources'"
role="menu"
[attr.aria-label]="label()"
data-test-id="vod-more-menu"
(keydown)="onMenuKeydown($event)"
>
@if (view() === 'menu') {
@for (section of sections(); track $index) {
@if (section.items.length) {
@if ($index > 0) {
<hr class="more-menu__divider" aria-hidden="true" />
}
@if (section.labelKey; as groupKey) {
<div class="more-menu__group" role="presentation">
{{ groupKey | translate }}
</div>
}
@for (item of section.items; track item.id) {
<button
type="button"
role="menuitem"
class="more-menu__item"
[disabled]="item.disabled"
[attr.data-test-id]="item.testId ?? null"
[attr.data-menu-item]="item.id"
(click)="select(item)"
>
@if (item.icon; as icon) {
<mat-icon
class="more-menu__icon"
aria-hidden="true"
>{{ icon }}</mat-icon
>
}
<span class="more-menu__label">{{
item.labelKey | translate: item.labelParams
}}</span>
@if (item.hint !== null && item.hint !== undefined) {
<span class="more-menu__hint">{{
item.hint
}}</span>
}
@if (item.kind === 'sources') {
<mat-icon
class="more-menu__chevron"
aria-hidden="true"
>chevron_right</mat-icon
>
}
</button>
}
}
}
} @else {
<button
type="button"
class="more-menu__item more-menu__back"
role="menuitem"
(click)="showMenu()"
>
<mat-icon class="more-menu__icon" aria-hidden="true"
>arrow_back</mat-icon
>
<span class="more-menu__label">{{ 'BACK' | translate }}</span>
</button>
<app-vod-sources-menu
class="more-menu__sources"
[sources]="sources()"
[title]="sourcesTitle()"
[matchKind]="matchKind()"
[autoFailoverEnabled]="autoFailoverEnabled()"
[autoFailoverSupported]="autoFailoverSupported()"
[resumeLabel]="resumeLabel()"
[playbackLive]="playbackLive()"
(playRequested)="onSourcePlay($event)"
(pinRequested)="onSourcePin($event)"
(checkRequested)="sourceCheckRequested.emit($event)"
(autoFailoverToggled)="autoFailoverToggled.emit($event)"
/>
}
</div>
</ng-template>
@@ -0,0 +1,151 @@
@use '../../../../styles/detail-view-actions' as actions;
:host {
display: inline-flex;
}
.more-trigger {
@include actions.ghost-action-surface;
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
width: 44px;
height: 44px;
padding: 0;
border-radius: 10px;
cursor: pointer;
mat-icon {
width: 20px;
height: 20px;
font-size: 20px;
line-height: 20px;
}
&:disabled {
cursor: default;
opacity: 0.55;
}
&--open {
background: color-mix(
in srgb,
var(--app-on-surface, #e6e1e5) 22%,
transparent
);
}
}
// The dropdown. An opaque widget surface: a CDK overlay floats over
// arbitrary page content, so a missing background is not cosmetic.
.more-menu {
display: flex;
flex-direction: column;
box-sizing: border-box;
min-width: 260px;
max-width: calc(100vw - 24px);
max-height: min(380px, 60vh);
padding: 6px;
overflow-y: auto;
scrollbar-width: thin;
border: 1px solid var(--app-separator, rgba(255, 255, 255, 0.1));
border-radius: 10px;
background: var(--app-widget-bg, #151c28);
color: var(--app-heading-color, #e7ecf3);
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35);
&--sources {
width: 660px;
max-height: min(560px, 80vh);
}
&__group {
padding: 8px 10px 4px;
color: var(--app-eyebrow-color, #6b7384);
font-size: 10.5px;
font-weight: 600;
letter-spacing: 1px;
text-transform: uppercase;
}
&__divider {
margin: 4px 2px;
border: 0;
border-top: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.06));
}
&__item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
box-sizing: border-box;
padding: 8px 10px;
border: 0;
border-radius: 6px;
background: none;
color: inherit;
font-family: inherit;
font-size: 13px;
line-height: 1.3;
text-align: left;
cursor: pointer;
&:hover:not(:disabled),
&:focus-visible {
background: var(--app-card-hover-bg, #1d2533);
outline: none;
}
&:disabled {
cursor: default;
opacity: 0.5;
}
}
&__icon {
flex: 0 0 auto;
width: 18px;
height: 18px;
font-size: 18px;
line-height: 18px;
color: var(--app-eyebrow-color, #9aa3b2);
}
&__label {
flex: 1 1 auto;
min-width: 0;
}
&__hint {
flex: 0 0 auto;
margin-left: auto;
max-width: 160px;
overflow: hidden;
color: var(--app-eyebrow-color, #6b7384);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
&__chevron {
flex: 0 0 auto;
width: 18px;
height: 18px;
font-size: 18px;
line-height: 18px;
color: var(--app-eyebrow-color, #6b7384);
}
&__back {
margin-bottom: 4px;
}
&__sources {
display: flex;
flex-direction: column;
min-height: 0;
}
}
@@ -0,0 +1,122 @@
import { OverlayContainer } from '@angular/cdk/overlay';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { VodMoreMenuComponent } from './vod-more-menu.component';
import type { VodMoreMenuSection } from './vod-more-menu.model';
const SECTIONS: VodMoreMenuSection[] = [
{
labelKey: 'GROUP_A',
items: [
{ id: 'copy', labelKey: 'COPY', testId: 'menu-copy' },
{ id: 'sources', labelKey: 'SOURCES', kind: 'sources', hint: 2 },
],
},
{
items: [{ id: 'reset', labelKey: 'RESET', disabled: true }],
},
];
describe('VodMoreMenuComponent', () => {
let fixture: ComponentFixture<VodMoreMenuComponent>;
let overlay: HTMLElement;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [VodMoreMenuComponent, TranslateModule.forRoot()],
}).compileComponents();
const translate = TestBed.inject(TranslateService);
translate.setTranslation('en', {
GROUP_A: 'Watching',
COPY: 'Copy stream URL',
SOURCES: 'Other sources',
RESET: 'Reset progress',
BACK: 'Back',
});
translate.use('en');
overlay = TestBed.inject(OverlayContainer).getContainerElement();
fixture = TestBed.createComponent(VodMoreMenuComponent);
fixture.componentRef.setInput('sections', SECTIONS);
fixture.componentRef.setInput('label', 'More');
fixture.componentRef.setInput('testId', 'more');
fixture.detectChanges();
});
afterEach(() => {
jest.useRealTimers();
});
function trigger(): HTMLButtonElement {
return (fixture.nativeElement as HTMLElement).querySelector(
'[data-testid="more"]'
)!;
}
function rows(): HTMLButtonElement[] {
return Array.from(
overlay.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')
);
}
it('opens on click with groups, hints and disabled rows, and emits the chosen id', () => {
trigger().click();
fixture.detectChanges();
expect(trigger().getAttribute('aria-expanded')).toBe('true');
expect(overlay.querySelector('[role="menu"]')).toBeTruthy();
expect(overlay.textContent).toContain('Watching');
expect(overlay.textContent).toContain('Other sources');
expect(overlay.querySelector('.more-menu__hint')?.textContent?.trim()).toBe('2');
const reset = rows().find((row) => row.textContent?.includes('Reset'))!;
expect(reset.disabled).toBe(true);
const spy = jest.fn();
fixture.componentInstance.selected.subscribe(spy);
overlay.querySelector<HTMLButtonElement>('[data-test-id="menu-copy"]')!.click();
fixture.detectChanges();
expect(spy).toHaveBeenCalledWith('copy');
expect(overlay.querySelector('[role="menu"]')).toBeNull();
});
it('moves focus with the arrow keys and closes on Escape', () => {
trigger().click();
fixture.detectChanges();
const [first, second] = rows();
first.focus();
const menu = overlay.querySelector<HTMLElement>('[role="menu"]')!;
menu.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
);
expect(document.activeElement).toBe(second);
menu.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })
);
expect(document.activeElement).toBe(first);
menu.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })
);
fixture.detectChanges();
expect(fixture.componentInstance.isOpen()).toBe(false);
});
it('switches to the sources panel and back', () => {
fixture.componentRef.setInput('sources', []);
trigger().click();
fixture.detectChanges();
rows()
.find((row) => row.textContent?.includes('Other sources'))!
.click();
fixture.detectChanges();
expect(fixture.componentInstance.view()).toBe('sources');
expect(overlay.querySelector('app-vod-sources-menu')).toBeTruthy();
overlay.querySelector<HTMLButtonElement>('.more-menu__back')!.click();
fixture.detectChanges();
expect(fixture.componentInstance.view()).toBe('menu');
});
it('disables the trigger when there are no rows', () => {
fixture.componentRef.setInput('sections', [{ items: [] }]);
fixture.detectChanges();
expect(trigger().disabled).toBe(true);
});
});
@@ -0,0 +1,210 @@
import {
CdkConnectedOverlay,
CdkOverlayOrigin,
type ConnectedPosition,
} from '@angular/cdk/overlay';
import {
ChangeDetectionStrategy,
Component,
computed,
ElementRef,
input,
output,
signal,
viewChild,
} from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import type {
VodSourceDescriptor,
VodSourceMatchKind,
} from '@iptvnator/shared/interfaces';
import { VodSourcesMenuComponent } from '../vod-sources/vod-sources-menu.component';
import type { VodMoreMenuItem, VodMoreMenuSection } from './vod-more-menu.model';
type MenuView = 'menu' | 'sources';
/**
* The "…" button of the details action row and its dropdown. Right edges
* align with the button so the panel never leaves the details column; when
* the space below is short the panel opens upward. Rows are plain buttons
* with `role="menuitem"`, reachable with the arrow keys; Escape and an
* outside click close the menu. Selecting the `sources` row swaps the panel
* to the alternative-sources list.
*/
@Component({
selector: 'app-vod-more-menu',
imports: [
CdkConnectedOverlay,
CdkOverlayOrigin,
MatIcon,
MatTooltip,
TranslatePipe,
VodSourcesMenuComponent,
],
templateUrl: './vod-more-menu.component.html',
styleUrl: './vod-more-menu.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class VodMoreMenuComponent {
readonly sections = input.required<readonly VodMoreMenuSection[]>();
readonly label = input.required<string>();
readonly testId = input<string | null>(null);
// Alternative sources, shown by the `sources` row.
readonly sources = input<readonly VodSourceDescriptor[]>([]);
readonly sourcesTitle = input('');
readonly matchKind = input<VodSourceMatchKind>('title-year');
readonly autoFailoverEnabled = input(false);
readonly autoFailoverSupported = input(true);
readonly playbackLive = input(false);
readonly resumeLabel = input<string | null>(null);
readonly selected = output<string>();
readonly sourcePlayRequested = output<string>();
readonly sourcePinRequested = output<string>();
readonly sourceCheckRequested = output<string>();
readonly autoFailoverToggled = output<boolean>();
readonly isOpen = signal(false);
readonly view = signal<MenuView>('menu');
readonly hasRows = computed(() =>
this.sections().some((section) => section.items.length > 0)
);
private readonly panel = viewChild<ElementRef<HTMLElement>>('panel');
private readonly trigger =
viewChild.required<ElementRef<HTMLButtonElement>>('triggerButton');
/** Below the button, right-aligned; above it when the space below is short. */
readonly overlayPositions: ConnectedPosition[] = [
{
originX: 'end',
originY: 'bottom',
overlayX: 'end',
overlayY: 'top',
offsetY: 6,
panelClass: 'vod-more-menu-pane--below',
},
{
originX: 'end',
originY: 'top',
overlayX: 'end',
overlayY: 'bottom',
offsetY: -6,
panelClass: 'vod-more-menu-pane--above',
},
];
toggle(): void {
if (this.isOpen()) {
this.close();
return;
}
this.view.set('menu');
this.isOpen.set(true);
this.focusRow(0);
}
close(restoreFocus = false): void {
if (!this.isOpen()) {
return;
}
this.isOpen.set(false);
this.view.set('menu');
if (restoreFocus) {
this.trigger().nativeElement.focus();
}
}
showMenu(): void {
this.view.set('menu');
this.focusRow(0);
}
select(item: VodMoreMenuItem): void {
if (item.disabled) {
return;
}
if (item.kind === 'sources') {
this.view.set('sources');
return;
}
this.close(true);
this.selected.emit(item.id);
}
onOverlayKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
event.preventDefault();
this.close(true);
} else if (event.key === 'Tab') {
this.close();
}
}
onTriggerKeydown(event: KeyboardEvent): void {
if (event.key === 'ArrowDown' && !this.isOpen()) {
event.preventDefault();
this.toggle();
}
}
onMenuKeydown(event: KeyboardEvent): void {
if (this.view() !== 'menu') {
return;
}
const rows = this.rows();
if (rows.length === 0) {
return;
}
const current = rows.indexOf(document.activeElement as HTMLElement);
let next: number | null = null;
switch (event.key) {
case 'ArrowDown':
next = (current + 1) % rows.length;
break;
case 'ArrowUp':
next = (current - 1 + rows.length) % rows.length;
break;
case 'Home':
next = 0;
break;
case 'End':
next = rows.length - 1;
break;
}
if (next !== null) {
event.preventDefault();
rows[next].focus();
}
}
/** Playing from a row hands the screen back to the player. */
onSourcePlay(sourceId: string): void {
this.close();
this.sourcePlayRequested.emit(sourceId);
}
onSourcePin(sourceId: string): void {
this.close();
this.sourcePinRequested.emit(sourceId);
}
private rows(): HTMLElement[] {
const panel = this.panel()?.nativeElement;
return panel
? Array.from(
panel.querySelectorAll<HTMLElement>(
'[role="menuitem"]:not([disabled])'
)
)
: [];
}
private focusRow(index: number): void {
// The overlay attaches after this change detection pass.
setTimeout(() => this.rows()[index]?.focus());
}
}
@@ -0,0 +1,19 @@
/** One row of the details "…" menu. Labels are i18n keys; hints are literal. */
export interface VodMoreMenuItem {
readonly id: string;
readonly labelKey: string;
readonly labelParams?: Record<string, string | number>;
/** Right-aligned literal hint: a count, a category, a player name. */
readonly hint?: string | number | null;
readonly icon?: string;
readonly disabled?: boolean;
readonly testId?: string;
/** `sources` swaps the panel to the alternative-sources list on select. */
readonly kind?: 'action' | 'sources';
}
/** A labelled group of rows; groups are separated by a hairline. */
export interface VodMoreMenuSection {
readonly labelKey?: string | null;
readonly items: readonly VodMoreMenuItem[];
}
@@ -1,36 +1,13 @@
import { isPortalPlaybackWatched } from '@iptvnator/portal/shared/util';
import {
isPortalPlaybackWatched,
parseDurationSeconds,
} from '@iptvnator/portal/shared/util';
import { PlaybackPositionData } from '@iptvnator/shared/interfaces';
/** Pure helpers for episode duration parsing and progress-text formatting. */
export function parseDuration(duration: string | number | undefined): number {
if (!duration) {
return 0;
}
if (typeof duration === 'number') {
return duration;
}
// Providers report "45 min" (Stalker VOD) and "1h 30min" (Xtream) —
// the hour component must not be dropped when both are present.
const minutesMatch = duration.match(/(?:(\d+)\s*h\w*)?\s*(\d+)\s*min/);
if (minutesMatch) {
return (
parseInt(minutesMatch[1] ?? '0', 10) * 3600 +
parseInt(minutesMatch[2], 10) * 60
);
}
const parts = duration.split(':').map(Number);
if (parts.length === 3) {
return parts[0] * 3600 + parts[1] * 60 + parts[2];
}
if (parts.length === 2) {
return parts[0] * 60 + parts[1];
}
return Number(duration) || 0;
return parseDurationSeconds(duration);
}
/**