feat(playback): show an "Up next" card in the last minutes of an episode

Phase 4 of the "Hybrid" player controls redesign. Series hosts hand the
episode after the playing one to the shared controls (`upNext` on
PortalInlinePlayerComponent → WebPlayerViewComponent → the four engine
hosts → app-player-controls). ControlsUpNext shows the glass card once at
most eight minutes remain on a seekable episode with a next-episode
capable engine; it yields to the settings panel and to hidden controls. A
click emits nextEpisodeRequested, so the switch keeps fullscreen like the
transport button. Compact docks get a smaller card.

Also dedupes the web-player-view spec's local engine stubs in favour of
the shared stub file its sibling specs already use.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-26 21:52:39 +02:00
1 parent e2a846b430
commit d0c1d49aee
51 files changed
+856 -96

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
---
type: feature
area: playback
highlight: Up next in the player
---
In the last minutes of a series episode the player shows an "Up next" card
with the following episode's still, title and time remaining. One click
plays it, and the switch keeps you in fullscreen.
@@ -123,6 +123,54 @@ test.describe('@xtream inline series fullscreen', () => {
expect(await fullscreenOwner()).not.toBeNull();
});
test('shows the Up next card near the end and plays the next episode from it', async ({
page,
request,
}) => {
await routeEpisodeClip(page);
await rewriteSeriesEpisodesToMp4(page);
await selectWebPlayer(page, 'HTML5 video player');
const { playerView, video } = await playFirstSeriesEpisode(
page,
request
);
await expect(playerView.locator('app-html-video-player')).toBeVisible({
timeout: 15_000,
});
await expect
.poll(() =>
video.evaluate((el) => (el as HTMLVideoElement).readyState)
)
.toBeGreaterThanOrEqual(1);
// The 30 s fixture clip is inside the card's threshold from its
// first frame, so the card is up as soon as the duration is known.
const card = playerView.locator(
'[data-test-id="player-controls-up-next"]'
);
await expect(card).toBeVisible();
await expect(card).toContainText('Up next');
await expect(card).toHaveAttribute('aria-label', /S01E02/);
// The settings panel takes the same corner: the card yields to it.
await playerView.hover();
await playerView
.locator('[data-test-id="player-controls-settings-button"]')
.click();
await expect(card).toHaveCount(0);
await page.keyboard.press('Escape');
await expect(card).toBeVisible();
// A click plays the next episode through the host's own path.
await card.click();
await expect(
page.locator('app-portal-inline-player .player-shell__episode-meta')
).toContainText('S01E02', { timeout: 15_000 });
await expect(card).toHaveAttribute('aria-label', /S01E03/, {
timeout: 15_000,
});
});
test('switches episodes from the fullscreen episode panel without leaving fullscreen', async ({
page,
request,
+2
View File
@@ -622,6 +622,8 @@
"CLEAR_SEARCH": "مسح البحث",
"LOAD_SUBTITLE_FILE": "تحميل ملف الترجمة…",
"SETTINGS": "الإعدادات",
"UP_NEXT": "التالي",
"UP_NEXT_IN": "خلال {{minutes}} دقيقة",
"CLOSE_SETTINGS": "إغلاق الإعدادات",
"SUBTITLE_DELAY": "تأخير الترجمة",
"SUBTITLE_DELAY_DECREASE": "عرض الترجمة مبكرًا",
+2
View File
@@ -622,6 +622,8 @@
"CLEAR_SEARCH": "مسح البحث",
"LOAD_SUBTITLE_FILE": "تحميل ملف الترجمة…",
"SETTINGS": "الإعدادات",
"UP_NEXT": "اللي جاي",
"UP_NEXT_IN": "فـ {{minutes}} دقيقة",
"CLOSE_SETTINGS": "سد الإعدادات",
"SUBTITLE_DELAY": "تأخير الترجمة",
"SUBTITLE_DELAY_DECREASE": "ورّي الترجمة قبل",
+2
View File
@@ -622,6 +622,8 @@
"CLEAR_SEARCH": "Ачысціць пошук",
"LOAD_SUBTITLE_FILE": "Загрузіць файл субтытраў…",
"SETTINGS": "Налады",
"UP_NEXT": "Далей",
"UP_NEXT_IN": "праз {{minutes}} хв",
"CLOSE_SETTINGS": "Закрыць налады",
"SUBTITLE_DELAY": "Затрымка субтытраў",
"SUBTITLE_DELAY_DECREASE": "Паказваць субтытры раней",
+2
View File
@@ -622,6 +622,8 @@
"CLEAR_SEARCH": "Suche löschen",
"LOAD_SUBTITLE_FILE": "Untertiteldatei laden…",
"SETTINGS": "Einstellungen",
"UP_NEXT": "Als Nächstes",
"UP_NEXT_IN": "in {{minutes}} Min.",
"CLOSE_SETTINGS": "Einstellungen schließen",
"SUBTITLE_DELAY": "Untertitelverzögerung",
"SUBTITLE_DELAY_DECREASE": "Untertitel früher anzeigen",
+2
View File
@@ -622,6 +622,8 @@
"CLEAR_SEARCH": "Καθαρισμός αναζήτησης",
"LOAD_SUBTITLE_FILE": "Φόρτωση αρχείου υποτίτλων…",
"SETTINGS": "Ρυθμίσεις",
"UP_NEXT": "Επόμενο",
"UP_NEXT_IN": "σε {{minutes}} λεπτά",
"CLOSE_SETTINGS": "Κλείσιμο ρυθμίσεων",
"SUBTITLE_DELAY": "Καθυστέρηση υποτίτλων",
"SUBTITLE_DELAY_DECREASE": "Εμφάνιση υποτίτλων νωρίτερα",
+2
View File
@@ -622,6 +622,8 @@
"CLEAR_SEARCH": "Clear search",
"LOAD_SUBTITLE_FILE": "Load subtitle file…",
"SETTINGS": "Settings",
"UP_NEXT": "Up next",
"UP_NEXT_IN": "in {{minutes}} min",
"CLOSE_SETTINGS": "Close settings",
"SUBTITLE_DELAY": "Subtitle delay",
"SUBTITLE_DELAY_DECREASE": "Show subtitles earlier",
+2
View File
@@ -622,6 +622,8 @@
"CLEAR_SEARCH": "Borrar búsqueda",
"LOAD_SUBTITLE_FILE": "Cargar archivo de subtítulos…",
"SETTINGS": "Ajustes",
"UP_NEXT": "A continuación",
"UP_NEXT_IN": "en {{minutes}} min",
"CLOSE_SETTINGS": "Cerrar ajustes",
"SUBTITLE_DELAY": "Retardo de subtítulos",
"SUBTITLE_DELAY_DECREASE": "Mostrar subtítulos antes",
+2
View File
@@ -622,6 +622,8 @@
"CLEAR_SEARCH": "Effacer la recherche",
"LOAD_SUBTITLE_FILE": "Charger un fichier de sous-titres…",
"SETTINGS": "Réglages",
"UP_NEXT": "À suivre",
"UP_NEXT_IN": "dans {{minutes}} min",
"CLOSE_SETTINGS": "Fermer les réglages",
"SUBTITLE_DELAY": "Décalage des sous-titres",
"SUBTITLE_DELAY_DECREASE": "Afficher les sous-titres plus tôt",
+2
View File
@@ -622,6 +622,8 @@
"CLEAR_SEARCH": "Keresés törlése",
"LOAD_SUBTITLE_FILE": "Feliratfájl betöltése…",
"SETTINGS": "Beállítások",
"UP_NEXT": "Következő",
"UP_NEXT_IN": "{{minutes}} perc múlva",
"CLOSE_SETTINGS": "Beállítások bezárása",
"SUBTITLE_DELAY": "Felirat késleltetése",
"SUBTITLE_DELAY_DECREASE": "Feliratok megjelenítése korábban",
+2
View File
@@ -622,6 +622,8 @@
"CLEAR_SEARCH": "Cancella ricerca",
"LOAD_SUBTITLE_FILE": "Carica file sottotitoli…",
"SETTINGS": "Impostazioni",
"UP_NEXT": "Prossimo",
"UP_NEXT_IN": "tra {{minutes}} min",
"CLOSE_SETTINGS": "Chiudi impostazioni",
"SUBTITLE_DELAY": "Ritardo sottotitoli",
"SUBTITLE_DELAY_DECREASE": "Mostra i sottotitoli prima",
+2
View File
@@ -622,6 +622,8 @@
"CLEAR_SEARCH": "検索をクリア",
"LOAD_SUBTITLE_FILE": "字幕ファイルを読み込む…",
"SETTINGS": "設定",
"UP_NEXT": "次のエピソード",
"UP_NEXT_IN": "あと{{minutes}}分",
"CLOSE_SETTINGS": "設定を閉じる",
"SUBTITLE_DELAY": "字幕の遅延",
"SUBTITLE_DELAY_DECREASE": "字幕を早く表示",
+2
View File
@@ -622,6 +622,8 @@
"CLEAR_SEARCH": "검색 지우기",
"LOAD_SUBTITLE_FILE": "자막 파일 불러오기…",
"SETTINGS": "설정",
"UP_NEXT": "다음 에피소드",
"UP_NEXT_IN": "{{minutes}}분 후",
"CLOSE_SETTINGS": "설정 닫기",
"SUBTITLE_DELAY": "자막 지연",
"SUBTITLE_DELAY_DECREASE": "자막을 더 빨리 표시",
+2
View File
@@ -622,6 +622,8 @@
"CLEAR_SEARCH": "Zoekopdracht wissen",
"LOAD_SUBTITLE_FILE": "Ondertitelbestand laden…",
"SETTINGS": "Instellingen",
"UP_NEXT": "Hierna",
"UP_NEXT_IN": "over {{minutes}} min",
"CLOSE_SETTINGS": "Instellingen sluiten",
"SUBTITLE_DELAY": "Ondertitelvertraging",
"SUBTITLE_DELAY_DECREASE": "Ondertitels eerder tonen",
+2
View File
@@ -622,6 +622,8 @@
"CLEAR_SEARCH": "Wyczyść wyszukiwanie",
"LOAD_SUBTITLE_FILE": "Wczytaj plik napisów…",
"SETTINGS": "Ustawienia",
"UP_NEXT": "Następny",
"UP_NEXT_IN": "za {{minutes}} min",
"CLOSE_SETTINGS": "Zamknij ustawienia",
"SUBTITLE_DELAY": "Opóźnienie napisów",
"SUBTITLE_DELAY_DECREASE": "Pokaż napisy wcześniej",
+2
View File
@@ -622,6 +622,8 @@
"CLEAR_SEARCH": "Limpar pesquisa",
"LOAD_SUBTITLE_FILE": "Carregar ficheiro de legendas…",
"SETTINGS": "Configurações",
"UP_NEXT": "A seguir",
"UP_NEXT_IN": "em {{minutes}} min",
"CLOSE_SETTINGS": "Fechar configurações",
"SUBTITLE_DELAY": "Atraso das legendas",
"SUBTITLE_DELAY_DECREASE": "Mostrar legendas mais cedo",
+2
View File
@@ -622,6 +622,8 @@
"CLEAR_SEARCH": "Очистить поиск",
"LOAD_SUBTITLE_FILE": "Загрузить файл субтитров…",
"SETTINGS": "Настройки",
"UP_NEXT": "Далее",
"UP_NEXT_IN": "через {{minutes}} мин",
"CLOSE_SETTINGS": "Закрыть настройки",
"SUBTITLE_DELAY": "Задержка субтитров",
"SUBTITLE_DELAY_DECREASE": "Показывать субтитры раньше",
+2
View File
@@ -622,6 +622,8 @@
"CLEAR_SEARCH": "Aramayı temizle",
"LOAD_SUBTITLE_FILE": "Altyazı dosyası yükle…",
"SETTINGS": "Ayarlar",
"UP_NEXT": "Sıradaki",
"UP_NEXT_IN": "{{minutes}} dk içinde",
"CLOSE_SETTINGS": "Ayarları kapat",
"SUBTITLE_DELAY": "Altyazı gecikmesi",
"SUBTITLE_DELAY_DECREASE": "Altyazıları daha erken göster",
+2
View File
@@ -622,6 +622,8 @@
"CLEAR_SEARCH": "清除搜索",
"LOAD_SUBTITLE_FILE": "加载字幕文件…",
"SETTINGS": "设置",
"UP_NEXT": "接下来",
"UP_NEXT_IN": "{{minutes}} 分钟后",
"CLOSE_SETTINGS": "关闭设置",
"SUBTITLE_DELAY": "字幕延迟",
"SUBTITLE_DELAY_DECREASE": "提前显示字幕",
+2
View File
@@ -622,6 +622,8 @@
"CLEAR_SEARCH": "清除搜尋",
"LOAD_SUBTITLE_FILE": "載入字幕檔案…",
"SETTINGS": "設定",
"UP_NEXT": "接下來",
"UP_NEXT_IN": "{{minutes}} 分鐘後",
"CLOSE_SETTINGS": "關閉設定",
"SUBTITLE_DELAY": "字幕延遲",
"SUBTITLE_DELAY_DECREASE": "提前顯示字幕",
+29 -1
View File
@@ -293,7 +293,9 @@ It owns only transient presentation behavior:
- `ControlsSettings` — the settings panel's groups, on/modified state and
open/close transitions (`controls-settings-groups.ts` holds the pure
group-availability rule);
- `app-player-settings-panel` — the panel / bottom sheet presentation; and
- `app-player-settings-panel` — the panel / bottom sheet presentation;
- `ControlsUpNext` and `app-player-up-next-card` — the "Up next" card's
gate and presentation; and
- `controls-view-model.ts` — derived display state.
### The dock
@@ -354,6 +356,28 @@ segment. Producers (EPG programmes for catch-up and timeshift playback,
mpv's chapter list) are separate follow-ups; the rendering, model and rules
are in place for them.
### Up next card
Near the end of a series episode the dock shows an **"Up next" card**
(`app-player-up-next-card`, `data-test-id="player-controls-up-next"`) in the
bottom-right corner above the controls: the next episode's still (or its
`S01E03` label as a tile), a 3px accent progress line when it was partly
watched, "Up next · in 7 min" and the title. The host supplies the item
through the optional `upNext` input (`PlayerUpNextItem { label, title,
thumbnailUrl, progressPercent }`); `ControlsUpNext` decides when it shows —
`seriesNavigation` capability, `canNextEpisode`, a finite duration with at
most `UP_NEXT_THRESHOLD_SECONDS` (8 min) left, not live, controls shown,
settings panel closed — and how many minutes remain (never below one). A
click emits `nextEpisodeRequested`, the same output the hosts already
handle, so the switch keeps fullscreen exactly like the transport button.
The card is a glass surface that does not fade with the controls; the
compact dock uses a smaller variant without the trailing icon.
Plumbing mirrors `mediaTitle`: `PortalInlinePlayerComponent.playerUpNext`
derives the item after the playing one from its `upNextEpisodes` input
(episodes only) → `WebPlayerViewComponent.upNext` → the four engine hosts →
`app-player-controls`. Movie and live hosts pass nothing.
### Settings panel
Every track, quality, speed and aspect choice lives behind one **`tune`**
@@ -1516,6 +1540,10 @@ libs/ui/playback/src/lib/player-controls/
├── controls-timeline-segments.ts
├── controls-settings.ts
├── controls-settings-groups.ts
├── controls-up-next.ts
├── player-up-next-card.component.ts
├── player-up-next-card.component.html
├── player-up-next-card.component.scss
├── player-settings-panel.component.ts
├── player-settings-panel.component.html
├── player-settings-panel.component.scss
@@ -12,6 +12,7 @@
[playerSurface]="playerRoot"
[fullscreenTarget]="fullscreenTarget()"
[mediaTitle]="mediaTitle()"
[upNext]="upNext()"
[showControls]="interactionEnabled()"
[shortcutsEnabled]="interactionEnabled()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
@@ -20,6 +20,7 @@ import {
type LegacyPlayerShortcuts,
PlayerControlsComponent,
type PlayerMediaTitle,
PlayerUpNextItem,
WEB_PLAYER_SHARED_CONTROLS,
WebVideoControlsAdapter,
} from '../player-controls';
@@ -59,6 +60,7 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
readonly isLive = input(true);
readonly interactionEnabled = input(true);
readonly mediaTitle = input<PlayerMediaTitle | null>(null);
readonly upNext = input<PlayerUpNextItem | null>(null);
/** See `PlayerControlsComponent.fullscreenTarget`; null keeps the shell. */
readonly fullscreenTarget = input<HTMLElement | null>(null);
@@ -103,6 +103,7 @@
[playerSurface]="playerSurface()"
[fullscreenTarget]="fullscreenTarget()"
[mediaTitle]="mediaTitle()"
[upNext]="upNext()"
[showControls]="showControls()"
[shortcutsEnabled]="sharedShortcutsEnabled()"
(previousEpisodeRequested)="requestPreviousEpisode()"
@@ -26,7 +26,10 @@ import {
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import { PlayerControlsComponent } from '../player-controls/player-controls.component';
import type { PlayerMediaTitle } from '../player-controls/player-controls.model';
import type {
PlayerMediaTitle,
PlayerUpNextItem,
} from '../player-controls/player-controls.model';
import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation';
import { EmbeddedMpvControlsAdapter } from './embedded-mpv-controls.adapter';
import { EmbeddedMpvDockPanelComponent } from './embedded-mpv-dock-panel.component';
@@ -82,6 +85,7 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
readonly recordingMetadata = input<RecordingStartMetadata | null>(null);
readonly seriesNavigation = input<SeriesPlaybackNavigation | null>(null);
readonly mediaTitle = input<PlayerMediaTitle | null>(null);
readonly upNext = input<PlayerUpNextItem | null>(null);
/** See `PlayerControlsComponent.fullscreenTarget`; null keeps the root. */
readonly fullscreenTarget = input<HTMLElement | null>(null);
@@ -12,6 +12,7 @@
[playerSurface]="playerRoot"
[fullscreenTarget]="fullscreenTarget()"
[mediaTitle]="mediaTitle()"
[upNext]="upNext()"
[showControls]="interactionEnabled()"
[shortcutsEnabled]="interactionEnabled()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
@@ -29,6 +29,7 @@ import {
type LegacyPlayerShortcuts,
PlayerControlsComponent,
type PlayerMediaTitle,
PlayerUpNextItem,
WEB_PLAYER_SHARED_CONTROLS,
WebVideoControlsAdapter,
} from '../player-controls';
@@ -81,6 +82,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
readonly interactionEnabled = input(true);
readonly showCaptions = input(false);
readonly mediaTitle = input<PlayerMediaTitle | null>(null);
readonly upNext = input<PlayerUpNextItem | null>(null);
/** See `PlayerControlsComponent.fullscreenTarget`; null keeps the shell. */
readonly fullscreenTarget = input<HTMLElement | null>(null);
@Output() timeUpdate = new EventEmitter<{
@@ -0,0 +1,130 @@
import { WritableSignal, signal } from '@angular/core';
import { ControlsUpNext, UP_NEXT_THRESHOLD_SECONDS } from './controls-up-next';
import {
DEFAULT_PLAYER_CAPABILITIES,
createEmptyControlsState,
} from './player-controls-defaults';
import type {
PlayerControlsCapabilities,
PlayerControlsState,
PlayerUpNextItem,
} from './player-controls.model';
describe('ControlsUpNext', () => {
const next: PlayerUpNextItem = {
label: 'S01E03',
title: 'The Third One',
thumbnailUrl: null,
progressPercent: null,
};
let item: WritableSignal<PlayerUpNextItem | null>;
let state: WritableSignal<PlayerControlsState>;
let capabilities: WritableSignal<PlayerControlsCapabilities>;
let showControls: WritableSignal<boolean>;
let settingsOpen: WritableSignal<boolean>;
let upNext: ControlsUpNext;
const setState = (overrides: Partial<PlayerControlsState>) =>
state.set({ ...createEmptyControlsState(), ...overrides });
beforeEach(() => {
item = signal(next);
state = signal(createEmptyControlsState());
capabilities = signal({
...DEFAULT_PLAYER_CAPABILITIES,
seriesNavigation: true,
});
showControls = signal(true);
settingsOpen = signal(false);
upNext = new ControlsUpNext({
item,
state,
capabilities,
showControls,
settingsOpen,
});
setState({
canNextEpisode: true,
durationSeconds: 1200,
positionSeconds: 1200 - 7 * 60 - 3,
});
});
it('shows the next episode inside the threshold with minutes left', () => {
expect(upNext.visible()).toBe(true);
expect(upNext.item()).toBe(next);
expect(upNext.remainingSeconds()).toBe(423);
expect(upNext.minutesLeft()).toBe(8);
});
it('stays hidden until the threshold and reports at least one minute', () => {
setState({
canNextEpisode: true,
durationSeconds: 1200,
positionSeconds: 1200 - UP_NEXT_THRESHOLD_SECONDS - 1,
});
expect(upNext.visible()).toBe(false);
setState({
canNextEpisode: true,
durationSeconds: 1200,
positionSeconds: 1200 - UP_NEXT_THRESHOLD_SECONDS,
});
expect(upNext.visible()).toBe(true);
setState({
canNextEpisode: true,
durationSeconds: 1200,
positionSeconds: 1199.5,
});
expect(upNext.minutesLeft()).toBe(1);
});
it('needs a next episode, a finite duration and a series-capable engine', () => {
item.set(null);
expect(upNext.visible()).toBe(false);
item.set(next);
setState({
canNextEpisode: false,
durationSeconds: 1200,
positionSeconds: 1100,
});
expect(upNext.visible()).toBe(false);
setState({
canNextEpisode: true,
durationSeconds: null,
positionSeconds: 1100,
});
expect(upNext.visible()).toBe(false);
expect(upNext.remainingSeconds()).toBeNull();
setState({
canNextEpisode: true,
isLive: true,
durationSeconds: 1200,
positionSeconds: 1100,
});
expect(upNext.visible()).toBe(false);
setState({
canNextEpisode: true,
durationSeconds: 1200,
positionSeconds: 1100,
});
capabilities.set({ ...DEFAULT_PLAYER_CAPABILITIES });
expect(upNext.visible()).toBe(false);
});
it('yields to hidden controls and to the open settings panel', () => {
showControls.set(false);
expect(upNext.visible()).toBe(false);
showControls.set(true);
settingsOpen.set(true);
expect(upNext.visible()).toBe(false);
settingsOpen.set(false);
expect(upNext.visible()).toBe(true);
});
});
@@ -0,0 +1,66 @@
import { Signal, computed } from '@angular/core';
import type {
PlayerControlsCapabilities,
PlayerControlsState,
PlayerUpNextItem,
} from './player-controls.model';
/** The card appears once this little of the episode is left. */
export const UP_NEXT_THRESHOLD_SECONDS = 8 * 60;
export interface ControlsUpNextDeps {
item: Signal<PlayerUpNextItem | null>;
state: Signal<PlayerControlsState>;
capabilities: Signal<PlayerControlsCapabilities>;
showControls: Signal<boolean>;
/** The settings panel covers the card's corner; it yields while open. */
settingsOpen: Signal<boolean>;
}
/**
* When the "Up next" card shows: a series host supplied the next episode,
* the engine can actually switch to it, the episode has a known length,
* and no more than the threshold of it is left. Live streams and open-ended
* VOD never qualify because they have no remaining time to count down.
*/
export class ControlsUpNext {
constructor(private readonly deps: ControlsUpNextDeps) {}
readonly remainingSeconds = computed(() => {
const { durationSeconds, positionSeconds } = this.deps.state();
if (
typeof durationSeconds !== 'number' ||
!Number.isFinite(durationSeconds) ||
durationSeconds <= 0
) {
return null;
}
return Math.max(0, durationSeconds - Math.max(0, positionSeconds));
});
readonly item = computed<PlayerUpNextItem | null>(() => {
const item = this.deps.item();
const remaining = this.remainingSeconds();
const state = this.deps.state();
if (
!item ||
remaining === null ||
remaining > UP_NEXT_THRESHOLD_SECONDS ||
state.isLive ||
!state.canNextEpisode ||
!this.deps.capabilities().seriesNavigation ||
!this.deps.showControls() ||
this.deps.settingsOpen()
) {
return null;
}
return item;
});
readonly visible = computed(() => this.item() !== null);
/** Whole minutes left, never below one while the card is showing. */
readonly minutesLeft = computed(() =>
Math.max(1, Math.ceil((this.remainingSeconds() ?? 0) / 60))
);
}
@@ -393,6 +393,90 @@ describe('PlayerControlsComponent dock', () => {
});
});
describe('up next card', () => {
const nearTheEnd = () =>
setState({
canSeek: true,
canNextEpisode: true,
durationSeconds: 1200,
positionSeconds: 1200 - 5 * 60,
});
beforeEach(() => {
setCapabilities({
seek: true,
seriesNavigation: true,
playbackSpeed: true,
});
fixture.componentRef.setInput('upNext', {
label: 'S01E03',
title: 'The Third One',
thumbnailUrl: null,
progressPercent: null,
});
fixture.detectChanges();
});
it('appears within the last minutes and plays the next episode on click', () => {
setState({
canSeek: true,
canNextEpisode: true,
durationSeconds: 1200,
positionSeconds: 60,
});
fixture.detectChanges();
expect(
query('[data-test-id="player-controls-up-next"]')
).toBeNull();
nearTheEnd();
fixture.detectChanges();
const card = query('[data-test-id="player-controls-up-next"]');
expect(card).not.toBeNull();
expect(card?.textContent).toContain('The Third One');
const next = jest.fn();
component.nextEpisodeRequested.subscribe(next);
card?.click();
expect(next).toHaveBeenCalledTimes(1);
});
it('yields to the settings panel and needs a next episode', () => {
nearTheEnd();
fixture.detectChanges();
expect(
query('[data-test-id="player-controls-up-next"]')
).not.toBeNull();
component.settings.open('speed');
fixture.detectChanges();
expect(
query('[data-test-id="player-controls-up-next"]')
).toBeNull();
component.settings.close();
fixture.detectChanges();
expect(
query('[data-test-id="player-controls-up-next"]')
).not.toBeNull();
fixture.componentRef.setInput('upNext', null);
fixture.detectChanges();
expect(
query('[data-test-id="player-controls-up-next"]')
).toBeNull();
});
it('uses the compact card on a compact dock', () => {
nearTheEnd();
resizeTo(400);
expect(
query('[data-test-id="player-controls-up-next"]')?.closest(
'.player-controls__up-next--compact'
)
).not.toBeNull();
});
});
describe('layout modes', () => {
beforeEach(() => {
setCapabilities({ volume: true });
@@ -689,4 +689,22 @@
(focusout)="chrome.onFocusOut($event)"
/>
}
@if (upNextCard.item(); as item) {
<app-player-up-next-card
class="player-controls__up-next"
[class.player-controls__up-next--compact]="isCompact()"
[item]="item"
[minutesLeft]="upNextCard.minutesLeft()"
[compact]="isCompact()"
(selected)="requestNextEpisode()"
(pointerenter)="chrome.onPointerEnter()"
(pointerleave)="chrome.onPointerLeave()"
(pointerdown)="chrome.onPointerDown()"
(click)="chrome.onClick($event)"
(keydown)="chrome.onKeyDown()"
(focusin)="chrome.onFocusIn($event)"
(focusout)="chrome.onFocusOut($event)"
/>
}
}
@@ -287,6 +287,23 @@
animation: player-controls-sheet-in 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
// "Up next" card: bottom-right, above the dock. It does not fade with the
// controls — near the end of an episode it is the thing worth seeing.
.player-controls__up-next {
position: absolute;
right: 32px;
bottom: 136px;
z-index: 3;
max-width: calc(100cqw - 64px);
animation: player-controls-panel-in 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.player-controls__up-next--compact {
right: 12px;
bottom: 92px;
max-width: calc(100cqw - 24px);
}
@keyframes player-controls-panel-in {
from {
opacity: 0;
@@ -26,6 +26,7 @@ import { ControlsStreamStats } from './controls-stream-stats';
import { ControlsSurface } from './controls-surface';
import { ControlsTimeline } from './controls-timeline';
import { ControlsTimelineHover } from './controls-timeline-hover';
import { ControlsUpNext } from './controls-up-next';
import { ControlsVisibility } from './controls-visibility';
import { createControlsViewModel } from './controls-view-model';
import { ControlsVolume } from './controls-volume';
@@ -36,8 +37,10 @@ import type {
PlayerController,
PlayerMediaTitle,
PlayerTimelineSegment,
PlayerUpNextItem,
} from './player-controls.model';
import { PlayerSettingsPanelComponent } from './player-settings-panel.component';
import { PlayerUpNextCardComponent } from './player-up-next-card.component';
@Component({
selector: 'app-player-controls',
@@ -49,6 +52,7 @@ import { PlayerSettingsPanelComponent } from './player-settings-panel.component'
MatTooltipModule,
TranslatePipe,
PlayerSettingsPanelComponent,
PlayerUpNextCardComponent,
],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
@@ -77,6 +81,8 @@ export class PlayerControlsComponent implements OnDestroy {
readonly timelineSegments = input<readonly PlayerTimelineSegment[] | null>(
null
);
/** The next episode, for the "Up next" card near the end of this one. */
readonly upNext = input<PlayerUpNextItem | null>(null);
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
readonly menus = new ControlsMenuState();
@@ -146,6 +152,13 @@ export class PlayerControlsComponent implements OnDestroy {
commands: () => this.controller().commands,
reveal: (options) => this.reveal(options),
});
readonly upNextCard = new ControlsUpNext({
item: this.upNext,
state: this.state,
capabilities: this.capabilities,
showControls: this.showControls,
settingsOpen: this.settings.isOpen,
});
private readonly controllerVolume = computed(() => this.state().volume);
readonly timeline = new ControlsTimeline(this.state, this.timelineSegments);
readonly scrubPosition = this.timeline.scrubPosition;
@@ -204,9 +217,6 @@ export class PlayerControlsComponent implements OnDestroy {
readonly isPaused = this.vm.isPaused;
readonly isPlaying = this.vm.isPlaying;
readonly canTogglePlay = this.vm.canTogglePlay;
readonly hasAudioTracks = this.vm.hasAudioTracks;
readonly hasSubtitleTracks = this.vm.hasSubtitleTracks;
readonly hasQualityLevels = this.vm.hasQualityLevels;
readonly canRecord = this.vm.canRecord;
readonly isRecording = this.vm.isRecording;
readonly recordingStatusText = this.vm.recordingStatusText;
@@ -69,6 +69,19 @@ export interface PlayerTimelineSegment {
title: string | null;
}
/**
* The episode after the current one, for the "Up next" card. Series hosts
* build it from their episode list; the controls decide when to show it.
*/
export interface PlayerUpNextItem {
/** `S01E03`. */
label: string;
title: string;
thumbnailUrl: string | null;
/** Watch progress in percent (0–100); null when never started. */
progressPercent: number | null;
}
export interface PlayerPreset<T> {
value: T;
label: string;
@@ -0,0 +1,51 @@
<button
type="button"
class="player-up-next__button"
data-test-id="player-controls-up-next"
(click)="selected.emit()"
[attr.aria-label]="
('EMBEDDED_MPV.PLAYER.UP_NEXT' | translate) +
': ' +
item().label +
(item().title ? ' – ' + item().title : '')
"
>
<span class="player-up-next__thumb" aria-hidden="true">
@if (item().thumbnailUrl) {
<img
class="player-up-next__image"
[src]="item().thumbnailUrl"
alt=""
loading="lazy"
/>
} @else {
<span class="player-up-next__placeholder">{{ item().label }}</span>
}
@if (item().progressPercent !== null) {
<span class="player-up-next__progress">
<span
class="player-up-next__progress-bar"
[style.width.%]="item().progressPercent"
></span>
</span>
}
</span>
<span class="player-up-next__meta">
<span class="player-up-next__eyebrow">
{{ 'EMBEDDED_MPV.PLAYER.UP_NEXT' | translate }}
·
{{
'EMBEDDED_MPV.PLAYER.UP_NEXT_IN'
| translate: { minutes: minutesLeft() }
}}
</span>
<span class="player-up-next__title">
{{ item().title || item().label }}
</span>
</span>
@if (!compact()) {
<mat-icon class="player-up-next__icon" aria-hidden="true"
>skip_next</mat-icon
>
}
</button>
@@ -0,0 +1,131 @@
// Glass card; the controls host positions it (bottom-right, above the dock)
// and reads the `--pc-*` palette it declares.
:host {
display: block;
pointer-events: auto;
}
.player-up-next__button {
display: flex;
align-items: center;
gap: 12px;
max-width: 320px;
padding: 8px 14px 8px 8px;
color: var(--pc-text, #e7ecf3);
background: var(--pc-glass-bg, rgba(12, 16, 23, 0.72));
border: 1px solid var(--pc-glass-border, rgba(255, 255, 255, 0.09));
border-radius: 14px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
backdrop-filter: blur(18px) saturate(1.3);
cursor: pointer;
font: inherit;
text-align: left;
transition:
background 120ms ease-out,
transform 120ms ease-out;
}
.player-up-next__button:hover {
background: rgba(12, 16, 23, 0.84);
transform: translateY(-1px);
}
.player-up-next__button:focus-visible {
outline: 2px solid var(--pc-text, #e7ecf3);
outline-offset: 2px;
}
.player-up-next__thumb {
position: relative;
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 88px;
height: 50px;
overflow: hidden;
border-radius: 8px;
background: rgba(255, 255, 255, 0.08);
}
.player-up-next__image {
width: 100%;
height: 100%;
object-fit: cover;
}
// No still: the episode label as a large numeral tile, so the card still
// looks designed without artwork.
.player-up-next__placeholder {
color: var(--pc-text-secondary, #9aa3b2);
font-size: 13px;
font-weight: 700;
letter-spacing: 0.04em;
}
.player-up-next__progress {
position: absolute;
right: 0;
bottom: 0;
left: 0;
height: 3px;
background: rgba(255, 255, 255, 0.18);
}
.player-up-next__progress-bar {
display: block;
height: 100%;
background: var(--pc-accent-blue, #4f8eff);
}
.player-up-next__meta {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.player-up-next__eyebrow {
color: var(--pc-text-secondary, #9aa3b2);
font-size: 11px;
letter-spacing: 0.04em;
text-transform: uppercase;
white-space: nowrap;
}
.player-up-next__title {
overflow: hidden;
font-size: 14px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.player-up-next__icon {
flex: 0 0 auto;
width: 22px;
height: 22px;
font-size: 22px;
color: var(--pc-text-secondary, #9aa3b2);
}
:host(.player-up-next--compact) .player-up-next__button {
gap: 10px;
max-width: 240px;
padding: 5px 10px 5px 5px;
border-radius: 10px;
}
:host(.player-up-next--compact) .player-up-next__thumb {
width: 56px;
height: 32px;
border-radius: 6px;
}
:host(.player-up-next--compact) .player-up-next__eyebrow {
font-size: 9px;
}
:host(.player-up-next--compact) .player-up-next__title {
font-size: 11.5px;
}
@@ -0,0 +1,89 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { PlayerUpNextCardComponent } from './player-up-next-card.component';
describe('PlayerUpNextCardComponent', () => {
let fixture: ComponentFixture<PlayerUpNextCardComponent>;
const query = (selector: string) =>
fixture.nativeElement.querySelector(selector) as HTMLElement | null;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PlayerUpNextCardComponent, TranslateModule.forRoot()],
}).compileComponents();
const translate = TestBed.inject(TranslateService);
translate.setTranslation('en', {
EMBEDDED_MPV: {
PLAYER: {
UP_NEXT: 'Up next',
UP_NEXT_IN: 'in {{minutes}} min',
},
},
});
translate.use('en');
fixture = TestBed.createComponent(PlayerUpNextCardComponent);
fixture.componentRef.setInput('item', {
label: 'S01E03',
title: 'The Third One',
thumbnailUrl: 'https://img.example/still.jpg',
progressPercent: 40,
});
fixture.componentRef.setInput('minutesLeft', 7);
fixture.detectChanges();
});
afterEach(() => fixture.destroy());
it('renders the still, progress, countdown and title, and names itself', () => {
expect(query('.player-up-next__image')?.getAttribute('src')).toBe(
'https://img.example/still.jpg'
);
expect(query('.player-up-next__progress-bar')?.style.width).toBe('40%');
expect(
query('.player-up-next__eyebrow')?.textContent?.replace(/\s+/g, ' ')
).toContain('Up next · in 7 min');
expect(query('.player-up-next__title')?.textContent?.trim()).toBe(
'The Third One'
);
expect(
query('[data-test-id="player-controls-up-next"]')?.getAttribute(
'aria-label'
)
).toBe('Up next: S01E03 – The Third One');
expect(query('.player-up-next__icon')).not.toBeNull();
});
it('falls back to the label tile without a still and hides the icon when compact', () => {
fixture.componentRef.setInput('item', {
label: 'S02E01',
title: '',
thumbnailUrl: null,
progressPercent: null,
});
fixture.componentRef.setInput('compact', true);
fixture.detectChanges();
expect(query('.player-up-next__image')).toBeNull();
expect(query('.player-up-next__placeholder')?.textContent?.trim()).toBe(
'S02E01'
);
expect(query('.player-up-next__progress')).toBeNull();
expect(query('.player-up-next__title')?.textContent?.trim()).toBe(
'S02E01'
);
expect(query('.player-up-next__icon')).toBeNull();
expect(fixture.nativeElement.classList).toContain(
'player-up-next--compact'
);
});
it('emits on click', () => {
const selected = jest.fn();
fixture.componentInstance.selected.subscribe(selected);
query('[data-test-id="player-controls-up-next"]')?.click();
expect(selected).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,32 @@
import {
ChangeDetectionStrategy,
Component,
input,
output,
} from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
import type { PlayerUpNextItem } from './player-controls.model';
/**
* The "Up next" card in the player's bottom-right corner: thumbnail, the
* minutes left, the next episode's label and title. One click plays it
* through the host's ordinary next-episode path, so fullscreen survives.
*/
@Component({
selector: 'app-player-up-next-card',
templateUrl: './player-up-next-card.component.html',
styleUrl: './player-up-next-card.component.scss',
imports: [MatIconModule, TranslatePipe],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'player-up-next',
'[class.player-up-next--compact]': 'compact()',
},
})
export class PlayerUpNextCardComponent {
readonly item = input.required<PlayerUpNextItem>();
readonly minutesLeft = input.required<number>();
readonly compact = input(false);
readonly selected = output<void>();
}
@@ -24,6 +24,7 @@ class StubWebPlayerViewComponent {
readonly streamUrl = input.required<string>();
readonly title = input('');
readonly mediaTitle = input<unknown>(null);
readonly upNext = input<unknown>(null);
readonly playback = input<unknown>(null);
readonly volume = input(1);
readonly playerOverride = input<unknown>(null);
@@ -25,6 +25,7 @@ class StubWebPlayerViewComponent {
readonly streamUrl = input.required<string>();
readonly title = input('');
readonly mediaTitle = input<unknown>(null);
readonly upNext = input<unknown>(null);
readonly playback = input<unknown>(null);
readonly volume = input(1);
readonly playerOverride = input<unknown>(null);
@@ -22,6 +22,7 @@ class StubWebPlayerViewComponent {
readonly streamUrl = input.required<string>();
readonly title = input('');
readonly mediaTitle = input<unknown>(null);
readonly upNext = input<unknown>(null);
readonly playback = input<unknown>(null);
readonly volume = input(1);
readonly playerOverride = input<unknown>(null);
@@ -115,6 +116,39 @@ describe('PortalInlinePlayerComponent up next rail', () => {
},
];
it('hands the episode after the playing one to the player as "up next"', async () => {
await setup(true);
fixture.componentRef.setInput('playback', seriesPlayback);
fixture.componentRef.setInput('upNextEpisodes', upNextItems);
fixture.detectChanges();
expect(component.playerUpNext()).toEqual({
label: 'S01E03',
title: 'Episode 3',
thumbnailUrl: null,
progressPercent: null,
});
// The last episode of the list has nothing after it.
fixture.componentRef.setInput('upNextEpisodes', [
{ ...upNextItems[1], isPlaying: true },
]);
fixture.detectChanges();
expect(component.playerUpNext()).toBeNull();
// Only episodes get a card: a movie never has a "next".
fixture.componentRef.setInput('upNextEpisodes', upNextItems);
fixture.componentRef.setInput('playback', {
...seriesPlayback,
contentInfo: {
...seriesPlayback.contentInfo,
contentType: 'movie',
},
});
fixture.detectChanges();
expect(component.playerUpNext()).toBeNull();
});
/** Renders, then feeds a stage size as the ResizeObserver would. */
function renderWithStage(width: number, height: number): void {
fixture.detectChanges();
@@ -77,6 +77,7 @@
[streamUrl]="streamUrl()"
[title]="title()"
[mediaTitle]="playerMediaTitle()"
[upNext]="playerUpNext()"
[playback]="playback()"
[volume]="volume()"
[playerOverride]="playerOverride()"
@@ -25,6 +25,7 @@ class StubWebPlayerViewComponent {
readonly streamUrl = input.required<string>();
readonly title = input('');
readonly mediaTitle = input<unknown>(null);
readonly upNext = input<unknown>(null);
readonly playback = input<unknown>(null);
readonly volume = input(1);
readonly playerOverride = input<unknown>(null);
@@ -28,7 +28,7 @@ import type { PlaybackFallbackRequest } from '@iptvnator/playback/util';
import type { PlaybackDiagnosticCode } from '@iptvnator/playback/util';
import { SettingsStore } from '@iptvnator/services';
import { applyChannelNameStrip } from '@iptvnator/shared/m3u-utils';
import type { PlayerMediaTitle } from '../player-controls';
import type { PlayerMediaTitle, PlayerUpNextItem } from '../player-controls';
import {
FULLSCREEN_CHANNEL_PANEL,
type FullscreenChannelPanelContext,
@@ -224,6 +224,21 @@ export class PortalInlinePlayerComponent {
readonly upNextRailItems = computed<UpNextRailItem[]>(
() => this.upNextEpisodes() ?? []
);
/** The episode after the playing one, for the controls' "Up next" card. */
readonly playerUpNext = computed<PlayerUpNextItem | null>(() => {
const items = this.upNextEpisodes() ?? [];
const playing = items.findIndex((item) => item.isPlaying);
const next = playing >= 0 ? items[playing + 1] : undefined;
if (!next || this.playback()?.contentInfo?.contentType !== 'episode') {
return null;
}
return {
label: next.label,
title: next.title,
thumbnailUrl: next.thumbnailUrl,
progressPercent: next.progressPercent,
};
});
private readonly fullscreenEpisodePanelTemplate = viewChild<
TemplateRef<FullscreenChannelPanelContext>
@@ -14,6 +14,7 @@
[playerSurface]="playerRoot"
[fullscreenTarget]="fullscreenTarget()"
[mediaTitle]="mediaTitle()"
[upNext]="upNext()"
[showControls]="interactionEnabled()"
[shortcutsEnabled]="interactionEnabled()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
@@ -24,6 +24,7 @@ import {
type LegacyPlayerShortcuts,
PlayerControlsComponent,
type PlayerMediaTitle,
PlayerUpNextItem,
WEB_PLAYER_SHARED_CONTROLS,
WebVideoControlsAdapter,
} from '../player-controls';
@@ -77,6 +78,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
readonly interactionEnabled = input(true);
readonly showCaptions = input(false);
readonly mediaTitle = input<PlayerMediaTitle | null>(null);
readonly upNext = input<PlayerUpNextItem | null>(null);
/** See `PlayerControlsComponent.fullscreenTarget`; null keeps the shell. */
readonly fullscreenTarget = input<HTMLElement | null>(null);
@@ -7,6 +7,7 @@
[options]="options"
[fullscreenTarget]="fullscreenSurface"
[mediaTitle]="resolvedMediaTitle()"
[upNext]="upNext()"
[volume]="volume()"
[showCaptions]="showCaptions()"
[interactionEnabled]="playbackInteractionEnabled()"
@@ -35,6 +36,7 @@
[channel]="$any(channel)"
[fullscreenTarget]="fullscreenSurface"
[mediaTitle]="resolvedMediaTitle()"
[upNext]="upNext()"
[volume]="volume()"
[showCaptions]="showCaptions()"
[isLive]="resolvedIsLive()"
@@ -64,6 +66,7 @@
[channel]="$any(channel)"
[fullscreenTarget]="fullscreenSurface"
[mediaTitle]="resolvedMediaTitle()"
[upNext]="upNext()"
[volume]="volume()"
[showCaptions]="showCaptions()"
[isLive]="resolvedIsLive()"
@@ -93,6 +96,7 @@
[playback]="resolvedPlayback()"
[fullscreenTarget]="fullscreenSurface"
[mediaTitle]="resolvedMediaTitle()"
[upNext]="upNext()"
[recordingFolder]="recordingFolder()"
[recordingMetadata]="recordingMetadata()"
[seriesNavigation]="seriesNavigation()"
@@ -1,4 +1,3 @@
import { Component, input, output, signal } from '@angular/core';
import {
ComponentFixture,
DeferBlockBehavior,
@@ -13,12 +12,7 @@ import { VodSourceRowComponent } from '@iptvnator/ui/components';
import { StorageMap } from '@ngx-pwa/local-storage';
import { TranslateModule } from '@ngx-translate/core';
import { of } from 'rxjs';
import {
VideoPlayer,
type EmbeddedMpvSupport,
type RecordingStartMetadata,
type RecordingStoppedEvent,
} from '@iptvnator/shared/interfaces';
import { VideoPlayer } from '@iptvnator/shared/interfaces';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import { ErrorDetails, ErrorTypes } from 'hls.js';
import type { WebPlayerViewComponent as WebPlayerViewComponentInstance } from './web-player-view.component';
@@ -35,7 +29,13 @@ import {
getDiagnosticMeta,
getDiagnosticTitleKey,
} from '../playback-diagnostic-panel/playback-diagnostic-view.util';
import { StubFullscreenChannelPanelComponent } from './web-player-view.spec-stubs';
import {
StubArtPlayerComponent,
StubEmbeddedMpvPlayerComponent,
StubFullscreenChannelPanelComponent,
StubHtmlVideoPlayerComponent,
StubVjsPlayerComponent,
} from './web-player-view.spec-stubs';
jest.unstable_mockModule('video.js', () => ({
default: jest.fn(),
@@ -45,88 +45,6 @@ jest.unstable_mockModule('@yangkghjh/videojs-aspect-ratio-panel', () => ({}));
jest.unstable_mockModule('videojs-contrib-quality-levels', () => ({}));
jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
@Component({
selector: 'app-vjs-player',
template: '<div data-test-id="stub-vjs-player"></div>',
})
class StubVjsPlayerComponent {
readonly options = input<unknown>();
readonly fullscreenTarget = input<HTMLElement | null>(null);
readonly mediaTitle = input<unknown>(null);
readonly volume = input(1);
readonly showCaptions = input(false);
readonly interactionEnabled = input(true);
readonly startTime = input(0);
readonly seriesNavigation = input<unknown>(null);
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
readonly playbackIssue = output<PlaybackDiagnostic | null>();
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
}
@Component({
selector: 'app-html-video-player',
template: '<div data-test-id="stub-html-player"></div>',
})
class StubHtmlVideoPlayerComponent {
readonly channel = input<unknown>();
readonly fullscreenTarget = input<HTMLElement | null>(null);
readonly mediaTitle = input<unknown>(null);
readonly volume = input(1);
readonly showCaptions = input(false);
readonly isLive = input(true);
readonly interactionEnabled = input(true);
readonly startTime = input(0);
readonly seriesNavigation = input<unknown>(null);
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
readonly playbackIssue = output<PlaybackDiagnostic | null>();
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
}
@Component({
selector: 'app-art-player',
template: '<div data-test-id="stub-art-player"></div>',
})
class StubArtPlayerComponent {
readonly channel = input<unknown>();
readonly fullscreenTarget = input<HTMLElement | null>(null);
readonly mediaTitle = input<unknown>(null);
readonly volume = input(1);
readonly showCaptions = input(false);
readonly isLive = input(true);
readonly interactionEnabled = input(true);
readonly startTime = input(0);
readonly seriesNavigation = input<unknown>(null);
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
readonly playbackIssue = output<PlaybackDiagnostic | null>();
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
}
@Component({
selector: 'app-embedded-mpv-player',
template: '<div data-test-id="stub-embedded-mpv-player"></div>',
})
class StubEmbeddedMpvPlayerComponent {
/** Read by the view's channel-panel gate (`EmbeddedMpvEngineReporter`). */
readonly support = signal<EmbeddedMpvSupport | null>(null);
readonly playback = input.required<unknown>();
readonly fullscreenTarget = input<HTMLElement | null>(null);
readonly mediaTitle = input<unknown>(null);
readonly recordingFolder = input('');
readonly recordingMetadata = input<RecordingStartMetadata | null>(null);
readonly seriesNavigation = input<unknown>(null);
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
readonly recordingStopped = output<RecordingStoppedEvent>();
}
describe('WebPlayerViewComponent', () => {
let WebPlayerViewComponent: typeof import('./web-player-view.component').WebPlayerViewComponent;
let fixture: ComponentFixture<WebPlayerViewComponentInstance>;
@@ -38,6 +38,7 @@ import { HtmlVideoPlayerComponent } from '../html-video-player/html-video-player
import { PlaybackDiagnosticPanelComponent } from '../playback-diagnostic-panel/playback-diagnostic-panel.component';
import {
type PlayerMediaTitle,
PlayerUpNextItem,
WEB_PLAYER_SHARED_CONTROLS,
} from '../player-controls';
import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation';
@@ -134,6 +135,8 @@ export class WebPlayerViewComponent implements OnDestroy {
readonly playerOverride = input<VideoPlayer | null>(null);
readonly seriesNavigation = input<SeriesPlaybackNavigation | null>(null);
readonly mediaTitle = input<PlayerMediaTitle | null>(null);
/** Next episode for the shared controls' "Up next" card; series hosts only. */
readonly upNext = input<PlayerUpNextItem | null>(null);
readonly alternativeSources = input<VodSourceDescriptor[]>([]);
/** Channel/EPG snapshot for the embedded-MPV recording tracker. */
readonly recordingMetadata = input<RecordingStartMetadata | null>(null);
@@ -20,6 +20,7 @@ export class StubVjsPlayerComponent {
readonly options = input<unknown>();
readonly fullscreenTarget = input<HTMLElement | null>(null);
readonly mediaTitle = input<unknown>(null);
readonly upNext = input<unknown>(null);
readonly volume = input(1);
readonly showCaptions = input(false);
readonly interactionEnabled = input(true);
@@ -41,6 +42,7 @@ export class StubHtmlVideoPlayerComponent {
readonly channel = input<unknown>();
readonly fullscreenTarget = input<HTMLElement | null>(null);
readonly mediaTitle = input<unknown>(null);
readonly upNext = input<unknown>(null);
readonly volume = input(1);
readonly showCaptions = input(false);
readonly isLive = input(true);
@@ -63,6 +65,7 @@ export class StubArtPlayerComponent {
readonly channel = input<unknown>();
readonly fullscreenTarget = input<HTMLElement | null>(null);
readonly mediaTitle = input<unknown>(null);
readonly upNext = input<unknown>(null);
readonly volume = input(1);
readonly showCaptions = input(false);
readonly isLive = input(true);
@@ -95,6 +98,7 @@ export class StubEmbeddedMpvPlayerComponent {
readonly playback = input.required<unknown>();
readonly fullscreenTarget = input<HTMLElement | null>(null);
readonly mediaTitle = input<unknown>(null);
readonly upNext = input<unknown>(null);
readonly recordingFolder = input('');
readonly recordingMetadata = input<RecordingStartMetadata | null>(null);
readonly seriesNavigation = input<unknown>(null);