feat(playback): add web player series navigation (#1049)

This commit is contained in:
4gray authored and GitHub committed 2026-06-13 10:19:57 +02:00
1 parent 83db27ef12
commit 8d672f8b81
16 files changed
+769 -30

No files matched your search

@@ -139,6 +139,33 @@ The click path must continue through each detail host's normal episode playback
method so recent-item updates, inline/external player selection, resume offsets,
and playback-position saving keep the same behavior as manual episode clicks.
## Series Inline Episode Continuation
Xtream and Stalker series detail views own current-episode state and pass a
`SeriesPlaybackNavigation` payload through `PortalInlinePlayerComponent` and
`WebPlayerViewComponent` to the active embedded player.
Current contract:
- Video.js, HTML5, ArtPlayer, and embedded MPV emit `playbackEnded` only for
real media EOF/`ended` events.
- Teardown, replacement, manual close, player reload, idle state, and playback
errors must not emit `playbackEnded`.
- Series previous/next controls render only when the series navigation payload is
present. Movies, live streams, radio streams, and non-series VOD must not show
those buttons.
- The shared navigation payload contains `canPrevious`, `canNext`, and
`autoplayEnabled`. Player controls disable previous on the first episode and
next on the last episode of the current season.
- Autoplay is enabled by default for series playback. On `playbackEnded`, the
portal detail host starts the next episode only when `canNext` is true for the
current season.
- Autoplay and Next never cross season boundaries or lazy-load another season.
Quick start remains the flow that may choose an episode from another loaded or
newly loaded season before playback begins.
- Previous switches directly to the previous episode in the current season. It
does not implement a restart-threshold behavior.
## Codec And Container Diagnostics
The shared `WebPlayerViewComponent` is the central browser-player viewport for M3U, Xtream, and Stalker inline playback, including live streams opened from favorites and recently viewed collections. Video.js, HTML5, and ArtPlayer report native media errors, HLS.js errors, mpegts.js errors, and HLS manifest codec metadata into the shared diagnostics classifier.
+2 -2
View File
@@ -184,8 +184,8 @@ Series inline playback behavior is shared across all three modes:
- `StalkerSeriesViewComponent` maps every mode into `mappedSeasons()` and derives the currently playing episode from `inlinePlayback.contentInfo.contentXtreamId`.
- The inline player header shows the current episode metadata below the title, for example `S01E03 - Episode title`.
- When experimental embedded MPV is active, the player receives previous/next episode state for the current season only.
- Embedded MPV autoplay is enabled by default. On MPV EOF (`ended`), Stalker starts the next episode only when it already exists in the current season's mapped episode list.
- Embedded players receive previous/next episode state for the current season only.
- Inline series autoplay is enabled by default. On player EOF (`ended`), Stalker starts the next episode only when it already exists in the current season's mapped episode list.
- Autoplay and Next stop at the last episode of the current season. They do not jump to the next season and do not lazy-load an unloaded `is_series=1` season. Quick start remains the only flow that may load another VOD-series season before playback.
- Previous is disabled on the first episode of the current season and otherwise switches directly to the previous episode.
@@ -1,4 +1,5 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { Channel } from '@iptvnator/shared/interfaces';
import type { ArtPlayerComponent as ArtPlayerComponentInstance } from './art-player.component';
@@ -31,9 +32,11 @@ class MockHls {
static isSupported = jest.fn(() => true);
readonly handlers = new Map<string, (...args: unknown[]) => void>();
readonly on = jest.fn((event: string, handler: (...args: unknown[]) => void) => {
this.handlers.set(event, handler);
});
readonly on = jest.fn(
(event: string, handler: (...args: unknown[]) => void) => {
this.handlers.set(event, handler);
}
);
readonly loadSource = jest.fn();
readonly attachMedia = jest.fn();
readonly destroy = jest.fn();
@@ -47,9 +50,11 @@ class MockHls {
class MockMpegTsPlayer {
readonly handlers = new Map<string, (...args: unknown[]) => void>();
readonly attachMediaElement = jest.fn();
readonly on = jest.fn((event: string, handler: (...args: unknown[]) => void) => {
this.handlers.set(event, handler);
});
readonly on = jest.fn(
(event: string, handler: (...args: unknown[]) => void) => {
this.handlers.set(event, handler);
}
);
readonly load = jest.fn();
readonly play = jest.fn();
readonly pause = jest.fn();
@@ -217,9 +222,11 @@ describe('ArtPlayerComponent', () => {
artPlayerInstances[0].video,
'https://example.com/live/channel.ts'
);
mpegTsInstances[0].handlers
.get('error')
?.('mediaError', 'unsupported codec', {});
mpegTsInstances[0].handlers.get('error')?.(
'mediaError',
'unsupported codec',
{}
);
expect(issues).toEqual([
expect.objectContaining({
@@ -231,6 +238,97 @@ describe('ArtPlayerComponent', () => {
]);
});
it('emits playbackEnded exactly once for a native ended event and not during reload or destroy', () => {
const events: string[] = [];
createComponent({
url: 'https://example.com/series/s01e02.mp4',
name: 'Episode 2',
});
(
component as unknown as {
playbackEnded: {
subscribe: (fn: () => void) => { unsubscribe: () => void };
};
}
).playbackEnded.subscribe(() => events.push('ended'));
artPlayerInstances[0].video.dispatchEvent(new Event('ended'));
fixture.componentRef.setInput('channel', {
url: 'https://example.com/series/s01e03.mp4',
name: 'Episode 3',
});
fixture.detectChanges();
fixture.destroy();
expect(events).toEqual(['ended']);
});
it('hides series navigation controls when series navigation is absent', () => {
createComponent({
url: 'https://example.com/movie.mp4',
name: 'Movie',
});
expect(
fixture.debugElement.query(
By.css('[data-test-id="series-playback-previous-episode"]')
)
).toBeNull();
expect(
fixture.debugElement.query(
By.css('[data-test-id="series-playback-next-episode"]')
)
).toBeNull();
});
it('renders series navigation controls with boundary disabled state', () => {
const events: string[] = [];
createComponent({
url: 'https://example.com/series/s01e10.mp4',
name: 'Episode 10',
});
fixture.componentRef.setInput('seriesNavigation', {
canPrevious: true,
canNext: false,
autoplayEnabled: true,
});
(
component as unknown as {
previousEpisodeRequested: {
subscribe: (fn: () => void) => { unsubscribe: () => void };
};
nextEpisodeRequested: {
subscribe: (fn: () => void) => { unsubscribe: () => void };
};
}
).previousEpisodeRequested.subscribe(() => events.push('previous'));
(
component as unknown as {
nextEpisodeRequested: {
subscribe: (fn: () => void) => { unsubscribe: () => void };
};
}
).nextEpisodeRequested.subscribe(() => events.push('next'));
fixture.detectChanges();
const previousButton = fixture.debugElement.query(
By.css('[data-test-id="series-playback-previous-episode"]')
);
const nextButton = fixture.debugElement.query(
By.css('[data-test-id="series-playback-next-episode"]')
);
expect(previousButton).not.toBeNull();
expect(previousButton.nativeElement.disabled).toBe(false);
expect(nextButton).not.toBeNull();
expect(nextButton.nativeElement.disabled).toBe(true);
previousButton.nativeElement.click();
nextButton.nativeElement.click();
expect(events).toEqual(['previous']);
});
function createComponent(channel: Pick<Channel, 'url' | 'name'>): void {
fixture = TestBed.createComponent(ArtPlayerComponent);
component = fixture.componentInstance;
@@ -24,6 +24,8 @@ import {
createPlaybackSourceMetadata,
getPlaybackMediaExtensionFromUrl,
} from '../playback-diagnostics/playback-diagnostics.util';
import { SeriesPlaybackNavigationControlsComponent } from '../portal-inline-player/series-playback-navigation-controls.component';
import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation';
type AudioTrackSelector = {
html: string | HTMLElement;
@@ -34,8 +36,18 @@ Artplayer.AUTO_PLAYBACK_TIMEOUT = 10000;
@Component({
selector: 'app-art-player',
imports: [],
template: `<div #artplayer class="artplayer-container"></div>`,
imports: [SeriesPlaybackNavigationControlsComponent],
template: `
<div class="art-player-shell">
<div #artplayer class="artplayer-container"></div>
<app-series-playback-navigation-controls
[navigation]="seriesNavigation"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
/>
</div>
`,
styles: [
`
:host {
@@ -43,6 +55,13 @@ Artplayer.AUTO_PLAYBACK_TIMEOUT = 10000;
width: 100%;
height: 100%;
}
.art-player-shell {
position: relative;
width: 100%;
height: 100%;
min-height: 0;
overflow: hidden;
}
.artplayer-container {
width: 100%;
height: 100%;
@@ -55,11 +74,15 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
@Input() volume = 1;
@Input() showCaptions = false;
@Input() startTime = 0;
@Input() seriesNavigation: SeriesPlaybackNavigation | null = null;
@Output() timeUpdate = new EventEmitter<{
currentTime: number;
duration: number;
}>();
@Output() playbackIssue = new EventEmitter<PlaybackDiagnostic | null>();
@Output() playbackEnded = new EventEmitter<void>();
@Output() previousEpisodeRequested = new EventEmitter<void>();
@Output() nextEpisodeRequested = new EventEmitter<void>();
private player!: Artplayer;
private hls: Hls | null = null;
@@ -82,6 +105,10 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
this.playbackIssue.emit(null);
};
private readonly handlePlaybackEnded = () => {
this.playbackEnded.emit();
};
ngOnInit(): void {
this.initPlayer();
}
@@ -122,6 +149,10 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
'playing',
this.clearPlaybackIssue
);
this.player.video?.removeEventListener(
'ended',
this.handlePlaybackEnded
);
this.player.destroy();
}
}
@@ -232,6 +263,7 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
this.clearPlaybackIssue
);
this.player.video.addEventListener('playing', this.clearPlaybackIssue);
this.player.video.addEventListener('ended', this.handlePlaybackEnded);
if (this.startTime > 0) {
this.player.on('ready', () => {
@@ -1 +1,14 @@
<video #videoPlayer id="video-player" autoplay="true" controls="true"></video>
<div class="html-video-player-shell">
<video
#videoPlayer
id="video-player"
autoplay="true"
controls="true"
></video>
<app-series-playback-navigation-controls
[navigation]="seriesNavigation"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
/>
</div>
@@ -10,3 +10,11 @@
width: 100%;
height: 100%;
}
.html-video-player-shell {
position: relative;
width: 100%;
height: 100%;
min-height: 0;
overflow: hidden;
}
@@ -1,5 +1,6 @@
import { SimpleChange } from '@angular/core';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { TranslateModule } from '@ngx-translate/core';
import { DataService } from '@iptvnator/services';
import { Channel } from '@iptvnator/shared/interfaces';
@@ -106,6 +107,10 @@ describe('HtmlVideoPlayerComponent', () => {
});
it('passes channel headers and stream URL to Electron header overrides', () => {
jest.spyOn(
component.videoPlayer.nativeElement,
'load'
).mockImplementation(() => undefined);
jest.spyOn(
component.videoPlayer.nativeElement,
'play'
@@ -130,6 +135,10 @@ describe('HtmlVideoPlayerComponent', () => {
});
it('clears Electron header overrides for channels without custom headers', () => {
jest.spyOn(
component.videoPlayer.nativeElement,
'load'
).mockImplementation(() => undefined);
jest.spyOn(
component.videoPlayer.nativeElement,
'play'
@@ -153,6 +162,36 @@ describe('HtmlVideoPlayerComponent', () => {
);
});
it('replaces and reloads native video sources when switching episodes', () => {
const video = component.videoPlayer.nativeElement;
const loadSpy = jest
.spyOn(video, 'load')
.mockImplementation(() => undefined);
const playSpy = jest.spyOn(video, 'play').mockResolvedValue(undefined);
component.playChannel({
...TEST_CHANNEL,
url: 'https://stream.example/series/s01e01.mp4',
});
component.playChannel({
...TEST_CHANNEL,
url: 'https://stream.example/series/s01e02.mp4',
});
const sources = Array.from(video.querySelectorAll('source'));
const [source] = sources;
expect(sources).toHaveLength(1);
expect(source?.src).toBe(
'https://stream.example/series/s01e02.mp4'
);
expect(source?.type).toBe('video/mp4');
expect(loadSpy).toHaveBeenCalledTimes(2);
expect(playSpy).toHaveBeenCalledTimes(2);
expect(loadSpy.mock.invocationCallOrder[1]).toBeLessThan(
playSpy.mock.invocationCallOrder[1]
);
});
it('emits a playback issue when the native video element reports an unsupported source', () => {
const issues: unknown[] = [];
component.channel = TEST_CHANNEL;
@@ -237,4 +276,89 @@ describe('HtmlVideoPlayerComponent', () => {
expect(issues[0].details).toContain('xhr setup failed');
expect(issues[0].details).toContain('"status":0');
});
it('emits playbackEnded exactly once for a native ended event and not during reload or destroy', () => {
const events: string[] = [];
(
component as unknown as {
playbackEnded: {
subscribe: (fn: () => void) => { unsubscribe: () => void };
};
}
).playbackEnded.subscribe(() => events.push('ended'));
jest.spyOn(
component.videoPlayer.nativeElement,
'load'
).mockImplementation(() => undefined);
jest.spyOn(
component.videoPlayer.nativeElement,
'play'
).mockResolvedValue(undefined);
component.videoPlayer.nativeElement.dispatchEvent(new Event('ended'));
component.playChannel({
...TEST_CHANNEL,
url: 'https://stream.example/series/s01e03.mp4',
});
fixture.destroy();
expect(events).toEqual(['ended']);
});
it('hides series navigation controls when series navigation is absent', () => {
expect(
fixture.debugElement.query(
By.css('[data-test-id="series-playback-previous-episode"]')
)
).toBeNull();
expect(
fixture.debugElement.query(
By.css('[data-test-id="series-playback-next-episode"]')
)
).toBeNull();
});
it('renders series navigation controls with boundary disabled state', () => {
const events: string[] = [];
fixture.componentRef.setInput('seriesNavigation', {
canPrevious: true,
canNext: false,
autoplayEnabled: true,
});
(
component as unknown as {
previousEpisodeRequested: {
subscribe: (fn: () => void) => { unsubscribe: () => void };
};
nextEpisodeRequested: {
subscribe: (fn: () => void) => { unsubscribe: () => void };
};
}
).previousEpisodeRequested.subscribe(() => events.push('previous'));
(
component as unknown as {
nextEpisodeRequested: {
subscribe: (fn: () => void) => { unsubscribe: () => void };
};
}
).nextEpisodeRequested.subscribe(() => events.push('next'));
fixture.detectChanges();
const previousButton = fixture.debugElement.query(
By.css('[data-test-id="series-playback-previous-episode"]')
);
const nextButton = fixture.debugElement.query(
By.css('[data-test-id="series-playback-next-episode"]')
);
expect(previousButton).not.toBeNull();
expect(previousButton.nativeElement.disabled).toBe(false);
expect(nextButton).not.toBeNull();
expect(nextButton.nativeElement.disabled).toBe(true);
previousButton.nativeElement.click();
nextButton.nativeElement.click();
expect(events).toEqual(['previous']);
});
});
@@ -25,6 +25,8 @@ import {
createPlaybackSourceMetadata,
getPlaybackMediaExtensionFromUrl,
} from '../playback-diagnostics/playback-diagnostics.util';
import { SeriesPlaybackNavigationControlsComponent } from '../portal-inline-player/series-playback-navigation-controls.component';
import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation';
const debugHtmlPlayer = createDevLogger('HtmlVideoPlayer');
@@ -35,6 +37,7 @@ const debugHtmlPlayer = createDevLogger('HtmlVideoPlayer');
selector: 'app-html-video-player',
templateUrl: './html-video-player.component.html',
styleUrls: ['./html-video-player.component.scss'],
imports: [SeriesPlaybackNavigationControlsComponent],
standalone: true,
})
export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
@@ -42,11 +45,15 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
@Input() channel!: Channel;
@Input() volume = 1;
@Input() startTime = 0;
@Input() seriesNavigation: SeriesPlaybackNavigation | null = null;
@Output() timeUpdate = new EventEmitter<{
currentTime: number;
duration: number;
}>();
@Output() playbackIssue = new EventEmitter<PlaybackDiagnostic | null>();
@Output() playbackEnded = new EventEmitter<void>();
@Output() previousEpisodeRequested = new EventEmitter<void>();
@Output() nextEpisodeRequested = new EventEmitter<void>();
private readonly dataService = inject(DataService);
@@ -96,6 +103,10 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
});
};
private readonly handlePlaybackEnded = (): void => {
this.playbackEnded.emit();
};
ngOnInit() {
this.videoPlayer.nativeElement.addEventListener(
'volumechange',
@@ -124,6 +135,10 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
'playing',
this.clearPlaybackIssue
);
this.videoPlayer.nativeElement.addEventListener(
'ended',
this.handlePlaybackEnded
);
}
/**
@@ -157,6 +172,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
this.mpegtsPlayer = null;
}
if (this.hls) this.hls.destroy();
this.clearNativeVideoSources(this.videoPlayer.nativeElement);
if (channel.url) {
this.playbackIssue.emit(null);
const url = channel.url + (channel.epgParams ?? '');
@@ -225,21 +241,32 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
this.handlePlayOperation();
} else {
debugHtmlPlayer('Using native video player');
this.addSourceToVideo(
this.replaceNativeVideoSource(
this.videoPlayer.nativeElement,
url,
'video/mp4'
);
this.videoPlayer.nativeElement.play();
this.handlePlayOperation();
}
}
}
addSourceToVideo(element: HTMLVideoElement, url: string, type: string) {
private clearNativeVideoSources(element: HTMLVideoElement): void {
element.removeAttribute('src');
element.replaceChildren();
}
private replaceNativeVideoSource(
element: HTMLVideoElement,
url: string,
type: string
): void {
this.clearNativeVideoSources(element);
const source = document.createElement('source');
source.src = url;
source.type = type;
element.appendChild(source);
element.load();
}
/**
@@ -366,6 +393,10 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
'playing',
this.clearPlaybackIssue
);
this.videoPlayer.nativeElement.removeEventListener(
'ended',
this.handlePlaybackEnded
);
if (this.mpegtsPlayer) {
this.mpegtsPlayer.pause();
this.mpegtsPlayer.unload();
@@ -0,0 +1,59 @@
:host {
position: absolute;
right: 50%;
bottom: clamp(54px, 8%, 76px);
z-index: 4;
display: block;
transform: translateX(50%);
pointer-events: none;
}
.series-playback-navigation-controls {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px;
border: 1px solid var(--mat-sys-outline-variant, rgba(255, 255, 255, 0.16));
border-radius: 999px;
background: color-mix(
in srgb,
var(--mat-sys-surface, #101010) 76%,
transparent
);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.28);
backdrop-filter: blur(16px);
pointer-events: auto;
}
.series-playback-navigation-controls__button {
--mdc-icon-button-state-layer-size: 40px;
width: 40px;
height: 40px;
color: var(--mat-sys-on-surface, #ffffff);
}
.series-playback-navigation-controls__button:disabled {
color: color-mix(
in srgb,
var(--mat-sys-on-surface, #ffffff) 34%,
transparent
);
}
.series-playback-navigation-controls__button mat-icon {
width: 22px;
height: 22px;
font-size: 22px;
}
@media (max-width: 599px) {
:host {
bottom: 64px;
}
.series-playback-navigation-controls {
gap: 4px;
padding: 4px;
}
}
@@ -0,0 +1,81 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import type { SeriesPlaybackNavigation } from './series-playback-navigation';
@Component({
selector: 'app-series-playback-navigation-controls',
imports: [MatButtonModule, MatIconModule, MatTooltipModule],
template: `
@if (navigation()) {
<nav
class="series-playback-navigation-controls"
data-test-id="series-playback-navigation-controls"
aria-label="Series episode navigation"
>
<button
mat-icon-button
type="button"
class="series-playback-navigation-controls__button"
data-test-id="series-playback-previous-episode"
[disabled]="!canPrevious()"
(click)="requestPreviousEpisode()"
aria-label="Previous episode"
matTooltip="Previous episode"
matTooltipPosition="above"
>
<mat-icon>skip_previous</mat-icon>
</button>
<button
mat-icon-button
type="button"
class="series-playback-navigation-controls__button"
data-test-id="series-playback-next-episode"
[disabled]="!canNext()"
(click)="requestNextEpisode()"
aria-label="Next episode"
matTooltip="Next episode"
matTooltipPosition="above"
>
<mat-icon>skip_next</mat-icon>
</button>
</nav>
}
`,
styleUrl: './series-playback-navigation-controls.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SeriesPlaybackNavigationControlsComponent {
readonly navigation = input<SeriesPlaybackNavigation | null>(null);
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
readonly canPrevious = computed(
() => this.navigation()?.canPrevious === true
);
readonly canNext = computed(() => this.navigation()?.canNext === true);
requestPreviousEpisode(): void {
if (!this.canPrevious()) {
return;
}
this.previousEpisodeRequested.emit();
}
requestNextEpisode(): void {
if (!this.canNext()) {
return;
}
this.nextEpisodeRequested.emit();
}
}
@@ -1,9 +1,17 @@
<video
#target
class="video-js"
controls
muted
playsinline
preload="none"
data-setup='{"liveui": true, "muted": false}'
></video>
<div class="vjs-player-shell">
<video
#target
class="video-js"
controls
muted
playsinline
preload="none"
data-setup='{"liveui": true, "muted": false}'
></video>
<app-series-playback-navigation-controls
[navigation]="seriesNavigation()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
/>
</div>
@@ -12,6 +12,14 @@
height: 100% !important;
}
.vjs-player-shell {
position: relative;
width: 100%;
height: 100%;
min-height: 0;
overflow: hidden;
}
/* Hide the captions settings item from the captions menu. */
.hide-captions {
.vjs-texttrack-settings,
@@ -1,5 +1,6 @@
import { SimpleChange } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import type { VjsPlayerComponent as VjsPlayerComponentInstance } from './vjs-player.component';
const videoJsMock = jest.fn();
@@ -26,6 +27,7 @@ jest.unstable_mockModule('mpegts.js', () => ({
describe('VjsPlayerComponent', () => {
let VjsPlayerComponent: typeof import('./vjs-player.component').VjsPlayerComponent;
let fixture: ComponentFixture<VjsPlayerComponentInstance>;
let component: VjsPlayerComponentInstance;
let player: VjsPlayerComponentInstance['player'];
@@ -58,12 +60,13 @@ describe('VjsPlayerComponent', () => {
load: jest.fn(),
play: jest.fn(),
});
videoJsMock.mockReset();
await TestBed.configureTestingModule({
imports: [VjsPlayerComponent],
}).compileComponents();
component =
TestBed.createComponent(VjsPlayerComponent).componentInstance;
fixture = TestBed.createComponent(VjsPlayerComponent);
component = fixture.componentInstance;
player = {
error: jest.fn(),
src: jest.fn(),
@@ -74,6 +77,10 @@ describe('VjsPlayerComponent', () => {
component.player = player;
});
afterEach(() => {
fixture.destroy();
});
it('uses signal-based inputs and outputs', () => {
const signalComponent = component as unknown as SignalApiShape;
@@ -328,8 +335,153 @@ describe('VjsPlayerComponent', () => {
expect(testComponent.player.duration).toHaveBeenCalledWith(164.072);
});
it('emits playbackEnded exactly once for a native ended event and not during reload or destroy', () => {
const events: string[] = [];
videoJsMock.mockReturnValue(createVideoJsPlayerMock());
fixture.componentRef.setInput('options', {
sources: [
{
src: 'https://example.com/series/s01e02.mp4',
type: 'video/mp4',
},
],
});
(
component as unknown as {
playbackEnded: {
subscribe: (fn: () => void) => { unsubscribe: () => void };
};
}
).playbackEnded.subscribe(() => events.push('ended'));
fixture.detectChanges();
fixture.nativeElement
.querySelector('video')
.dispatchEvent(new Event('ended'));
component.ngOnChanges({
options: new SimpleChange(
{
sources: [
{
src: 'https://example.com/series/s01e02.mp4',
type: 'video/mp4',
},
],
},
{
sources: [
{
src: 'https://example.com/series/s01e03.mp4',
type: 'video/mp4',
},
],
},
false
),
});
fixture.destroy();
expect(events).toEqual(['ended']);
});
it('hides series navigation controls when series navigation is absent', () => {
videoJsMock.mockReturnValue(createVideoJsPlayerMock());
fixture.componentRef.setInput('options', {
sources: [
{
src: 'https://example.com/movie.mp4',
type: 'video/mp4',
},
],
});
fixture.detectChanges();
expect(
fixture.debugElement.query(
By.css('[data-test-id="series-playback-previous-episode"]')
)
).toBeNull();
expect(
fixture.debugElement.query(
By.css('[data-test-id="series-playback-next-episode"]')
)
).toBeNull();
});
it('renders series navigation controls with boundary disabled state', () => {
const events: string[] = [];
videoJsMock.mockReturnValue(createVideoJsPlayerMock());
fixture.componentRef.setInput('options', {
sources: [
{
src: 'https://example.com/series/s01e01.mp4',
type: 'video/mp4',
},
],
});
fixture.componentRef.setInput('seriesNavigation', {
canPrevious: false,
canNext: true,
autoplayEnabled: true,
});
(
component as unknown as {
previousEpisodeRequested: {
subscribe: (fn: () => void) => { unsubscribe: () => void };
};
nextEpisodeRequested: {
subscribe: (fn: () => void) => { unsubscribe: () => void };
};
}
).previousEpisodeRequested.subscribe(() => events.push('previous'));
(
component as unknown as {
nextEpisodeRequested: {
subscribe: (fn: () => void) => { unsubscribe: () => void };
};
}
).nextEpisodeRequested.subscribe(() => events.push('next'));
fixture.detectChanges();
const previousButton = fixture.debugElement.query(
By.css('[data-test-id="series-playback-previous-episode"]')
);
const nextButton = fixture.debugElement.query(
By.css('[data-test-id="series-playback-next-episode"]')
);
expect(previousButton).not.toBeNull();
expect(previousButton.nativeElement.disabled).toBe(true);
expect(nextButton).not.toBeNull();
expect(nextButton.nativeElement.disabled).toBe(false);
previousButton.nativeElement.click();
nextButton.nativeElement.click();
expect(events).toEqual(['next']);
});
});
function createVideoJsPlayerMock(): VjsPlayerComponentInstance['player'] {
return {
audioTracks: jest.fn(() => null),
currentTime: jest.fn(() => 0),
duration: jest.fn(() => 0),
error: jest.fn(() => null),
getChild: jest.fn(() => null),
on: jest.fn(),
reset: jest.fn(),
src: jest.fn(),
tech: jest.fn(() => ({ el: () => null })),
volume: jest.fn(),
dispose: jest.fn(),
qualitySelectorHls: jest.fn(),
aspectRatioPanel: jest.fn(),
} as unknown as VjsPlayerComponentInstance['player'];
}
function createTimeRanges(ranges: Array<[number, number]>): TimeRanges {
return {
length: ranges.length,
@@ -24,6 +24,8 @@ import {
createPlaybackSourceMetadata,
getPlaybackMediaExtensionFromUrl,
} from '../playback-diagnostics/playback-diagnostics.util';
import { SeriesPlaybackNavigationControlsComponent } from '../portal-inline-player/series-playback-navigation-controls.component';
import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation';
/**
* This component contains the implementation of video player that is based on video.js library
@@ -98,6 +100,7 @@ const debugVjsPlayer = createDevLogger('VjsPlayer');
templateUrl: './vjs-player.component.html',
styleUrls: ['./vjs-player.component.scss'],
encapsulation: ViewEncapsulation.None,
imports: [SeriesPlaybackNavigationControlsComponent],
standalone: true,
})
export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
@@ -119,15 +122,22 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
] as const;
readonly volume = input(1);
readonly startTime = input(0);
readonly seriesNavigation = input<SeriesPlaybackNavigation | null>(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>();
private readonly clearPlaybackIssue = () => {
this.playbackIssue.emit(null);
};
private readonly handlePlaybackEnded = () => {
this.playbackEnded.emit();
};
private readonly scheduleMpegTsVodDurationSync = () => {
this.syncMpegTsVodDuration();
this.queueDurationSync(() => this.syncMpegTsVodDuration());
@@ -142,6 +152,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
const targetVideo = this.target().nativeElement as HTMLVideoElement;
targetVideo.addEventListener('loadeddata', this.clearPlaybackIssue);
targetVideo.addEventListener('playing', this.clearPlaybackIssue);
targetVideo.addEventListener('ended', this.handlePlaybackEnded);
// For raw MPEG-TS streams, init Video.js without a source (UI/controls only)
const vjsOptions = isMpegTs
@@ -303,6 +314,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
this.clearPlaybackIssue
);
targetVideo.removeEventListener('playing', this.clearPlaybackIssue);
targetVideo.removeEventListener('ended', this.handlePlaybackEnded);
} catch {
// Required viewChild can be unavailable when a shallow unit test destroys an unrendered component.
}
@@ -4,8 +4,12 @@
[options]="vjsOptions"
[volume]="volume()"
[startTime]="startTime()"
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="timeUpdate.emit($event)"
(playbackIssue)="handlePlaybackIssue($event)"
(playbackEnded)="playbackEnded.emit()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
/>
} @placeholder {
<div class="web-player-defer-placeholder" aria-hidden="true"></div>
@@ -17,8 +21,12 @@
[volume]="volume()"
[showCaptions]="showCaptions()"
[startTime]="startTime()"
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="timeUpdate.emit($event)"
(playbackIssue)="handlePlaybackIssue($event)"
(playbackEnded)="playbackEnded.emit()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
/>
} @placeholder {
<div class="web-player-defer-placeholder" aria-hidden="true"></div>
@@ -30,8 +38,12 @@
[volume]="volume()"
[showCaptions]="showCaptions()"
[startTime]="startTime()"
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="timeUpdate.emit($event)"
(playbackIssue)="handlePlaybackIssue($event)"
(playbackEnded)="playbackEnded.emit()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
/>
} @placeholder {
<div class="web-player-defer-placeholder" aria-hidden="true"></div>
@@ -52,8 +64,12 @@
[options]="vjsOptions"
[volume]="volume()"
[startTime]="startTime()"
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="timeUpdate.emit($event)"
(playbackIssue)="handlePlaybackIssue($event)"
(playbackEnded)="playbackEnded.emit()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
/>
} @placeholder {
<div class="web-player-defer-placeholder" aria-hidden="true"></div>
@@ -37,8 +37,12 @@ class StubVjsPlayerComponent {
readonly options = input<unknown>();
readonly volume = input(1);
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({
@@ -50,8 +54,12 @@ class StubHtmlVideoPlayerComponent {
readonly volume = input(1);
readonly showCaptions = input(false);
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({
@@ -63,8 +71,12 @@ class StubArtPlayerComponent {
readonly volume = input(1);
readonly showCaptions = input(false);
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({
@@ -347,7 +359,10 @@ describe('WebPlayerViewComponent', () => {
it('suppresses browser diagnostics while embedded MPV is selected', () => {
const requests: unknown[] = [];
runtimeCapabilities.supportsManagedExternalPlayers = true;
fixture.componentRef.setInput('playerOverride', VideoPlayer.EmbeddedMpv);
fixture.componentRef.setInput(
'playerOverride',
VideoPlayer.EmbeddedMpv
);
component.externalFallbackRequested.subscribe((request) =>
requests.push(request)
);
@@ -429,6 +444,61 @@ describe('WebPlayerViewComponent', () => {
expect(events).toEqual(['ended', 'previous', 'next']);
});
it.each([
{
player: VideoPlayer.VideoJs,
directive: StubVjsPlayerComponent,
},
{
player: VideoPlayer.Html5Player,
directive: StubHtmlVideoPlayerComponent,
},
{
player: VideoPlayer.ArtPlayer,
directive: StubArtPlayerComponent,
},
])(
'passes series navigation to $player and forwards episode navigation events',
async ({ player: selectedPlayer, directive }) => {
const events: string[] = [];
const seriesNavigation = {
canPrevious: true,
canNext: false,
autoplayEnabled: true,
};
fixture.componentRef.setInput('playerOverride', selectedPlayer);
fixture.componentRef.setInput('seriesNavigation', seriesNavigation);
component.playbackEnded.subscribe(() => events.push('ended'));
component.previousEpisodeRequested.subscribe(() =>
events.push('previous')
);
component.nextEpisodeRequested.subscribe(() => events.push('next'));
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const playerElement = fixture.debugElement.query(
By.directive(directive)
);
expect(playerElement).not.toBeNull();
const player = playerElement.componentInstance as {
seriesNavigation: () => unknown;
playbackEnded: { emit: () => void };
previousEpisodeRequested: { emit: () => void };
nextEpisodeRequested: { emit: () => void };
};
expect(player.seriesNavigation()).toBe(seriesNavigation);
player.playbackEnded.emit();
player.previousEpisodeRequested.emit();
player.nextEpisodeRequested.emit();
expect(events).toEqual(['ended', 'previous', 'next']);
}
);
it('uses the PWA browser access diagnostic description key outside desktop', () => {
const issue = createBrowserAccessDiagnostic();