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

@@ -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);