mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 03:56:15 -08:00
983 lines
34 KiB
TypeScript
983 lines
34 KiB
TypeScript
import { Component, input, output } from '@angular/core';
|
||
import {
|
||
ComponentFixture,
|
||
DeferBlockBehavior,
|
||
TestBed,
|
||
} from '@angular/core/testing';
|
||
import { ClipboardModule } from '@angular/cdk/clipboard';
|
||
import { MatButtonModule } from '@angular/material/button';
|
||
import { MatIconModule } from '@angular/material/icon';
|
||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||
import { By } from '@angular/platform-browser';
|
||
import { VodSourceRowComponent } from '@iptvnator/ui/components';
|
||
import { StorageMap } from '@ngx-pwa/local-storage';
|
||
import { TranslateModule } from '@ngx-translate/core';
|
||
import { of, Subject } from 'rxjs';
|
||
import { VideoPlayer } from '@iptvnator/shared/interfaces';
|
||
import { RuntimeCapabilitiesService } from '@iptvnator/services';
|
||
import { ErrorDetails, ErrorTypes } from 'hls.js';
|
||
import type { WebPlayerViewComponent as WebPlayerViewComponentInstance } from './web-player-view.component';
|
||
import {
|
||
PlaybackDiagnostic,
|
||
PlaybackDiagnosticCode,
|
||
PlaybackDiagnosticSource,
|
||
} from '../playback-diagnostics/playback-diagnostics.util';
|
||
|
||
jest.unstable_mockModule('video.js', () => ({
|
||
default: jest.fn(),
|
||
}));
|
||
|
||
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 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 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 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 {
|
||
readonly playback = input.required<unknown>();
|
||
readonly mediaTitle = input<unknown>(null);
|
||
readonly recordingFolder = input('');
|
||
readonly seriesNavigation = input<unknown>(null);
|
||
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
|
||
readonly playbackEnded = output<void>();
|
||
readonly previousEpisodeRequested = output<void>();
|
||
readonly nextEpisodeRequested = output<void>();
|
||
}
|
||
|
||
describe('WebPlayerViewComponent', () => {
|
||
let WebPlayerViewComponent: typeof import('./web-player-view.component').WebPlayerViewComponent;
|
||
let fixture: ComponentFixture<WebPlayerViewComponentInstance>;
|
||
let component: WebPlayerViewComponentInstance;
|
||
const storageMap = {
|
||
get: jest.fn(() => of({ player: VideoPlayer.VideoJs })),
|
||
};
|
||
let runtimeCapabilities: { supportsManagedExternalPlayers: boolean };
|
||
|
||
beforeAll(async () => {
|
||
({ WebPlayerViewComponent } =
|
||
await import('./web-player-view.component'));
|
||
});
|
||
|
||
beforeEach(async () => {
|
||
runtimeCapabilities = { supportsManagedExternalPlayers: false };
|
||
|
||
await TestBed.configureTestingModule({
|
||
// @defer blocks render their main content synchronously in tests.
|
||
deferBlockBehavior: DeferBlockBehavior.Playthrough,
|
||
imports: [WebPlayerViewComponent, TranslateModule.forRoot()],
|
||
providers: [
|
||
{ provide: StorageMap, useValue: storageMap },
|
||
{
|
||
provide: RuntimeCapabilitiesService,
|
||
useValue: runtimeCapabilities,
|
||
},
|
||
],
|
||
})
|
||
.overrideComponent(WebPlayerViewComponent, {
|
||
set: {
|
||
imports: [
|
||
StubArtPlayerComponent,
|
||
StubEmbeddedMpvPlayerComponent,
|
||
StubHtmlVideoPlayerComponent,
|
||
StubVjsPlayerComponent,
|
||
// Real, not stubbed: the point of the test below is
|
||
// that this row's Check action reaches the host.
|
||
VodSourceRowComponent,
|
||
ClipboardModule,
|
||
MatButtonModule,
|
||
MatIconModule,
|
||
MatTooltipModule,
|
||
TranslateModule,
|
||
],
|
||
},
|
||
})
|
||
.compileComponents();
|
||
|
||
storageMap.get.mockReturnValue(of({ player: VideoPlayer.VideoJs }));
|
||
fixture = TestBed.createComponent(WebPlayerViewComponent);
|
||
component = fixture.componentInstance;
|
||
fixture.componentRef.setInput(
|
||
'streamUrl',
|
||
'https://example.com/archive/movie.mkv'
|
||
);
|
||
fixture.componentRef.setInput('title', 'Example Movie');
|
||
});
|
||
|
||
afterEach(() => {
|
||
fixture.destroy();
|
||
});
|
||
|
||
it('anchors overlay UI to the player view host', () => {
|
||
fixture.detectChanges();
|
||
|
||
expect(fixture.nativeElement.classList).toContain('web-player-view');
|
||
});
|
||
|
||
describe('resolvedMediaTitle', () => {
|
||
it('prefers an explicit media title over the playback title', async () => {
|
||
fixture.componentRef.setInput('mediaTitle', {
|
||
primary: 'Breaking Code',
|
||
secondary: 'S01E02',
|
||
});
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
fixture.detectChanges();
|
||
|
||
expect(component.resolvedMediaTitle()).toEqual({
|
||
primary: 'Breaking Code',
|
||
secondary: 'S01E02',
|
||
});
|
||
|
||
const player = fixture.debugElement.query(
|
||
By.directive(StubVjsPlayerComponent)
|
||
).componentInstance as StubVjsPlayerComponent;
|
||
expect(player.mediaTitle()).toEqual({
|
||
primary: 'Breaking Code',
|
||
secondary: 'S01E02',
|
||
});
|
||
});
|
||
|
||
it('falls back to the playback title as a single line', () => {
|
||
fixture.detectChanges();
|
||
|
||
expect(component.resolvedMediaTitle()).toEqual({
|
||
primary: 'Example Movie',
|
||
secondary: null,
|
||
});
|
||
});
|
||
|
||
it('is null when the title falls back to the raw stream URL', () => {
|
||
fixture.componentRef.setInput('title', '');
|
||
fixture.detectChanges();
|
||
|
||
expect(component.resolvedMediaTitle()).toBeNull();
|
||
});
|
||
});
|
||
|
||
it('relays the alternative row’s Check action out of the error screen', () => {
|
||
const checked: string[] = [];
|
||
component.sourceCheckRequested.subscribe((id) => checked.push(id));
|
||
fixture.componentRef.setInput('alternativeSources', [
|
||
{
|
||
id: 'playlist-2:xtream:991',
|
||
playlistId: 'playlist-2',
|
||
playlistName: 'Portal Two',
|
||
portalType: 'xtream',
|
||
contentId: 991,
|
||
rawTitle: 'Example Movie',
|
||
matchConfidence: 'exact',
|
||
year: null,
|
||
isActive: false,
|
||
isPinned: false,
|
||
isTried: false,
|
||
probe: { status: 'idle' },
|
||
},
|
||
]);
|
||
|
||
fixture.detectChanges();
|
||
component.handlePlaybackIssue(createUnsupportedContainerDiagnostic());
|
||
fixture.detectChanges();
|
||
|
||
const check = fixture.debugElement.query(
|
||
By.css('app-vod-source-row .source-tag--action')
|
||
);
|
||
expect(check).not.toBeNull();
|
||
check.nativeElement.click();
|
||
|
||
// Without the relay the button is visibly actionable and inert: the
|
||
// row emits into an output nobody observes, so no probe ever runs.
|
||
expect(checked).toEqual(['playlist-2:xtream:991']);
|
||
});
|
||
|
||
it('renders diagnostics and emits MPV fallback requests when managed external players are available', () => {
|
||
const requests: unknown[] = [];
|
||
runtimeCapabilities.supportsManagedExternalPlayers = true;
|
||
component.externalFallbackRequested.subscribe((request) =>
|
||
requests.push(request)
|
||
);
|
||
|
||
fixture.detectChanges();
|
||
component.handlePlaybackIssue(createUnsupportedContainerDiagnostic());
|
||
fixture.detectChanges();
|
||
|
||
const banner = fixture.debugElement.query(
|
||
By.css('[data-test-id="playback-diagnostic-banner"]')
|
||
);
|
||
const mpvButton = fixture.debugElement.query(
|
||
By.css('[data-test-id="playback-fallback-mpv"]')
|
||
);
|
||
|
||
expect(banner.nativeElement.textContent).toContain(
|
||
'PLAYBACK_DIAGNOSTICS.UNSUPPORTED_CONTAINER.TITLE'
|
||
);
|
||
expect(banner.nativeElement.textContent).toContain(
|
||
'PLAYBACK_DIAGNOSTICS.NATIVE_FALLBACK_TITLE'
|
||
);
|
||
mpvButton.nativeElement.click();
|
||
|
||
expect(requests).toEqual([
|
||
expect.objectContaining({
|
||
player: 'mpv',
|
||
playback: expect.objectContaining({
|
||
streamUrl: 'https://example.com/archive/movie.mkv',
|
||
title: 'Example Movie',
|
||
}),
|
||
diagnostic: expect.objectContaining({
|
||
code: PlaybackDiagnosticCode.UnsupportedContainer,
|
||
}),
|
||
}),
|
||
]);
|
||
});
|
||
|
||
it('renders explicit HTTP evidence without recommending an external fallback', () => {
|
||
runtimeCapabilities.supportsManagedExternalPlayers = true;
|
||
const issue = createHttpDiagnostic();
|
||
|
||
fixture.detectChanges();
|
||
component.handlePlaybackIssue(issue);
|
||
fixture.detectChanges();
|
||
|
||
const banner = fixture.debugElement.query(
|
||
By.css('[data-test-id="playback-diagnostic-banner"]')
|
||
);
|
||
const mpvButton = fixture.debugElement.query(
|
||
By.css('[data-test-id="playback-fallback-mpv"]')
|
||
);
|
||
|
||
expect(banner.nativeElement.textContent).toContain('HTTP 404');
|
||
expect(mpvButton).toBeNull();
|
||
expect(component.getDiagnosticMeta(issue)).toBe('HTTP 404');
|
||
expect(component.getDiagnosticDetails(issue)).toEqual(
|
||
expect.arrayContaining([
|
||
{
|
||
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_ERROR_DETAILS',
|
||
value: 'HTTP 404 · networkrequestfailed',
|
||
},
|
||
])
|
||
);
|
||
});
|
||
|
||
it('renders only sanitized structured HLS evidence in technical details', () => {
|
||
const issue = createStructuredHlsDiagnostic();
|
||
|
||
component.handlePlaybackIssue(issue);
|
||
fixture.detectChanges();
|
||
|
||
const details = component.getDiagnosticDetails(issue);
|
||
const renderedDetails = details.map(({ value }) => value).join(' ');
|
||
|
||
expect(details).toEqual(
|
||
expect.arrayContaining([
|
||
{
|
||
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_ERROR_DETAILS',
|
||
value:
|
||
'stage=manifest · failure=http · ' +
|
||
'type=networkError · details=manifestLoadError · ' +
|
||
'disposition=fatal · HTTP 404',
|
||
},
|
||
])
|
||
);
|
||
expect(renderedDetails).not.toContain('diagnostic-url-secret');
|
||
expect(renderedDetails).not.toContain('provider.example');
|
||
});
|
||
|
||
it('renders only sanitized structured VHS evidence in technical details', () => {
|
||
const issue = createStructuredVhsDiagnostic();
|
||
|
||
component.handlePlaybackIssue(issue);
|
||
fixture.detectChanges();
|
||
|
||
const details = component.getDiagnosticDetails(issue);
|
||
const renderedDetails = details.map(({ value }) => value).join(' ');
|
||
|
||
expect(details).toEqual(
|
||
expect.arrayContaining([
|
||
{
|
||
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_SOURCE',
|
||
value: 'Video.js / VHS',
|
||
},
|
||
{
|
||
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_ERROR_DETAILS',
|
||
value:
|
||
'stage=unknown · type=networkbadstatus · code=4 · ' +
|
||
'disposition=terminal · HTTP 503',
|
||
},
|
||
])
|
||
);
|
||
expect(renderedDetails).not.toContain('vhs-render-secret');
|
||
expect(renderedDetails).not.toContain('provider.example');
|
||
expect(renderedDetails).not.toContain('Authorization');
|
||
expect(renderedDetails).not.toContain('response body');
|
||
});
|
||
|
||
it('keeps query-declared HLS streams on the HLS mime type', () => {
|
||
const streamUrl =
|
||
'https://example.com/play?extension=m3u8&token=signed';
|
||
|
||
component.setVjsOptions(streamUrl);
|
||
|
||
expect(component.vjsOptions.sources).toEqual([
|
||
{
|
||
src: streamUrl,
|
||
type: 'application/x-mpegURL',
|
||
},
|
||
]);
|
||
});
|
||
|
||
it('uses the Matroska mime type for MKV paths', () => {
|
||
const streamUrl = 'https://example.com/archive/movie.mkv';
|
||
|
||
component.setVjsOptions(streamUrl);
|
||
|
||
expect(component.vjsOptions.sources).toEqual([
|
||
{
|
||
src: streamUrl,
|
||
type: 'video/matroska',
|
||
},
|
||
]);
|
||
});
|
||
|
||
it('uses the Matroska mime type for query-declared MKV streams', () => {
|
||
const streamUrl = 'https://example.com/play?container=mkv&token=signed';
|
||
|
||
component.setVjsOptions(streamUrl);
|
||
|
||
expect(component.vjsOptions.sources).toEqual([
|
||
{
|
||
src: streamUrl,
|
||
type: 'video/matroska',
|
||
},
|
||
]);
|
||
});
|
||
|
||
it('treats web script playback URLs without declared media extension as MPEG-TS', () => {
|
||
const streamUrl = 'https://example.com/live.php?stream=123&token=x';
|
||
|
||
component.setVjsOptions(streamUrl);
|
||
|
||
expect(component.vjsOptions.sources).toEqual([
|
||
{
|
||
src: streamUrl,
|
||
type: 'video/mp2t',
|
||
},
|
||
]);
|
||
});
|
||
|
||
it('marks portal VOD playback as non-live for Video.js MPEG-TS playback', async () => {
|
||
const streamUrl = 'https://example.com/movie/123.ts';
|
||
fixture.componentRef.setInput('playback', {
|
||
streamUrl,
|
||
title: 'Example Movie',
|
||
contentInfo: {
|
||
playlistId: 'playlist-1',
|
||
contentXtreamId: 123,
|
||
contentType: 'vod',
|
||
},
|
||
});
|
||
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
fixture.detectChanges();
|
||
|
||
const player = fixture.debugElement.query(
|
||
By.directive(StubVjsPlayerComponent)
|
||
).componentInstance as StubVjsPlayerComponent;
|
||
expect(player.options()).toEqual(
|
||
expect.objectContaining({
|
||
isLive: false,
|
||
sources: [
|
||
{
|
||
src: streamUrl,
|
||
type: 'video/mp2t',
|
||
},
|
||
],
|
||
})
|
||
);
|
||
});
|
||
|
||
it('preserves playback HTTP metadata for channel-based players', async () => {
|
||
const streamUrl = 'https://example.com/live/channel.m3u8';
|
||
fixture.componentRef.setInput(
|
||
'playerOverride',
|
||
VideoPlayer.Html5Player
|
||
);
|
||
fixture.componentRef.setInput('playback', {
|
||
streamUrl,
|
||
title: 'Header Locked Channel',
|
||
userAgent: 'ProviderAgent/1.0',
|
||
referer: 'https://provider.example/ref',
|
||
origin: 'https://provider.example',
|
||
headers: {
|
||
'User-Agent': 'IgnoredFallbackAgent/1.0',
|
||
Referer: 'https://ignored.example/ref',
|
||
Origin: 'https://ignored.example',
|
||
},
|
||
});
|
||
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
fixture.detectChanges();
|
||
|
||
const player = fixture.debugElement.query(
|
||
By.directive(StubHtmlVideoPlayerComponent)
|
||
).componentInstance as StubHtmlVideoPlayerComponent;
|
||
expect(player.channel()).toEqual(
|
||
expect.objectContaining({
|
||
url: streamUrl,
|
||
name: 'Header Locked Channel',
|
||
http: {
|
||
'user-agent': 'ProviderAgent/1.0',
|
||
referrer: 'https://provider.example/ref',
|
||
origin: 'https://provider.example',
|
||
},
|
||
})
|
||
);
|
||
});
|
||
|
||
it('falls back to playback headers when explicit HTTP metadata is absent', async () => {
|
||
const streamUrl = 'https://example.com/live/channel.m3u8';
|
||
fixture.componentRef.setInput(
|
||
'playerOverride',
|
||
VideoPlayer.Html5Player
|
||
);
|
||
fixture.componentRef.setInput('playback', {
|
||
streamUrl,
|
||
title: 'Header Fallback Channel',
|
||
headers: {
|
||
'user-agent': 'HeaderAgent/1.0',
|
||
referer: 'https://headers.example/ref',
|
||
origin: 'https://headers.example',
|
||
},
|
||
});
|
||
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
fixture.detectChanges();
|
||
|
||
const player = fixture.debugElement.query(
|
||
By.directive(StubHtmlVideoPlayerComponent)
|
||
).componentInstance as StubHtmlVideoPlayerComponent;
|
||
expect(player.channel()).toEqual(
|
||
expect.objectContaining({
|
||
http: {
|
||
'user-agent': 'HeaderAgent/1.0',
|
||
referrer: 'https://headers.example/ref',
|
||
origin: 'https://headers.example',
|
||
},
|
||
})
|
||
);
|
||
});
|
||
|
||
it('renders embedded MPV before settings storage emits', () => {
|
||
fixture.destroy();
|
||
|
||
const pendingSettings = new Subject<unknown>();
|
||
storageMap.get.mockReturnValue(pendingSettings.asObservable());
|
||
fixture = TestBed.createComponent(WebPlayerViewComponent);
|
||
component = fixture.componentInstance;
|
||
fixture.componentRef.setInput(
|
||
'streamUrl',
|
||
'https://example.com/archive/movie.mkv'
|
||
);
|
||
fixture.componentRef.setInput('title', 'Example Movie');
|
||
fixture.componentRef.setInput(
|
||
'playerOverride',
|
||
VideoPlayer.EmbeddedMpv
|
||
);
|
||
|
||
expect(() => fixture.detectChanges()).not.toThrow();
|
||
|
||
const player = fixture.debugElement.query(
|
||
By.directive(StubEmbeddedMpvPlayerComponent)
|
||
).componentInstance as StubEmbeddedMpvPlayerComponent;
|
||
expect(player.recordingFolder()).toBe('');
|
||
});
|
||
|
||
it('suppresses browser diagnostics while embedded MPV is selected', () => {
|
||
const requests: unknown[] = [];
|
||
runtimeCapabilities.supportsManagedExternalPlayers = true;
|
||
fixture.componentRef.setInput(
|
||
'playerOverride',
|
||
VideoPlayer.EmbeddedMpv
|
||
);
|
||
component.externalFallbackRequested.subscribe((request) =>
|
||
requests.push(request)
|
||
);
|
||
|
||
fixture.detectChanges();
|
||
component.handlePlaybackIssue(createUnsupportedCodecDiagnostic());
|
||
fixture.detectChanges();
|
||
component.requestExternalFallback('mpv');
|
||
|
||
expect(component.playbackDiagnostic()).toBeNull();
|
||
expect(
|
||
fixture.debugElement.query(
|
||
By.directive(StubEmbeddedMpvPlayerComponent)
|
||
)
|
||
).not.toBeNull();
|
||
expect(
|
||
fixture.debugElement.query(
|
||
By.css('[data-test-id="playback-diagnostic-banner"]')
|
||
)
|
||
).toBeNull();
|
||
expect(
|
||
fixture.debugElement.query(
|
||
By.css('[data-test-id="playback-fallback-mpv"]')
|
||
)
|
||
).toBeNull();
|
||
expect(
|
||
fixture.debugElement.query(
|
||
By.css('[data-test-id="playback-fallback-vlc"]')
|
||
)
|
||
).toBeNull();
|
||
expect(requests).toEqual([]);
|
||
});
|
||
|
||
it('passes series navigation to embedded MPV and forwards episode navigation events', () => {
|
||
const events: string[] = [];
|
||
const seriesNavigation = {
|
||
canPrevious: true,
|
||
canNext: false,
|
||
autoplayEnabled: true,
|
||
};
|
||
fixture.componentRef.setInput(
|
||
'playerOverride',
|
||
VideoPlayer.EmbeddedMpv
|
||
);
|
||
fixture.componentRef.setInput('seriesNavigation', seriesNavigation);
|
||
(
|
||
component as unknown as {
|
||
playbackEnded: { subscribe: (fn: () => void) => void };
|
||
previousEpisodeRequested: {
|
||
subscribe: (fn: () => void) => void;
|
||
};
|
||
nextEpisodeRequested: { subscribe: (fn: () => void) => void };
|
||
}
|
||
).playbackEnded.subscribe(() => events.push('ended'));
|
||
(
|
||
component as unknown as {
|
||
previousEpisodeRequested: {
|
||
subscribe: (fn: () => void) => void;
|
||
};
|
||
}
|
||
).previousEpisodeRequested.subscribe(() => events.push('previous'));
|
||
(
|
||
component as unknown as {
|
||
nextEpisodeRequested: { subscribe: (fn: () => void) => void };
|
||
}
|
||
).nextEpisodeRequested.subscribe(() => events.push('next'));
|
||
|
||
fixture.detectChanges();
|
||
|
||
const player = fixture.debugElement.query(
|
||
By.directive(StubEmbeddedMpvPlayerComponent)
|
||
).componentInstance as StubEmbeddedMpvPlayerComponent;
|
||
expect(player.seriesNavigation()).toBe(seriesNavigation);
|
||
|
||
player.playbackEnded.emit();
|
||
player.previousEpisodeRequested.emit();
|
||
player.nextEpisodeRequested.emit();
|
||
|
||
expect(events).toEqual(['ended', 'previous', 'next']);
|
||
});
|
||
|
||
it.each([
|
||
{
|
||
player: VideoPlayer.VideoJs,
|
||
directive: StubVjsPlayerComponent,
|
||
},
|
||
{
|
||
player: VideoPlayer.Html5Player,
|
||
directive: StubHtmlVideoPlayerComponent,
|
||
},
|
||
{
|
||
player: VideoPlayer.ArtPlayer,
|
||
directive: StubArtPlayerComponent,
|
||
},
|
||
])(
|
||
'passes volume to $player inline player',
|
||
async ({ player, directive }) => {
|
||
fixture.componentRef.setInput('playerOverride', player);
|
||
fixture.componentRef.setInput('volume', 0.42);
|
||
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
fixture.detectChanges();
|
||
|
||
const playerElement = fixture.debugElement.query(
|
||
By.directive(directive)
|
||
);
|
||
expect(playerElement).not.toBeNull();
|
||
|
||
const playerInstance = playerElement.componentInstance as {
|
||
volume: () => number;
|
||
};
|
||
expect(playerInstance.volume()).toBe(0.42);
|
||
}
|
||
);
|
||
|
||
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();
|
||
|
||
expect(component.getDiagnosticTitleKey(issue)).toBe(
|
||
'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.TITLE'
|
||
);
|
||
expect(component.getDiagnosticDescriptionKey(issue)).toBe(
|
||
'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.PWA_DESCRIPTION'
|
||
);
|
||
});
|
||
|
||
it('keeps the desktop browser access diagnostic description key', () => {
|
||
runtimeCapabilities.supportsManagedExternalPlayers = true;
|
||
const issue = createBrowserAccessDiagnostic();
|
||
|
||
expect(component.getDiagnosticDescriptionKey(issue)).toBe(
|
||
'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.DESCRIPTION'
|
||
);
|
||
});
|
||
|
||
it('uses an inline recovery headline when external fallback actions are unavailable', () => {
|
||
fixture.detectChanges();
|
||
component.handlePlaybackIssue(createNetworkDiagnostic());
|
||
fixture.detectChanges();
|
||
|
||
const banner = fixture.debugElement.query(
|
||
By.css('[data-test-id="playback-diagnostic-banner"]')
|
||
);
|
||
const mpvButton = fixture.debugElement.query(
|
||
By.css('[data-test-id="playback-fallback-mpv"]')
|
||
);
|
||
|
||
expect(mpvButton).toBeNull();
|
||
expect(banner.nativeElement.textContent).toContain(
|
||
'PLAYBACK_DIAGNOSTICS.NETWORK_ERROR.TITLE'
|
||
);
|
||
expect(banner.nativeElement.textContent).toContain(
|
||
'PLAYBACK_DIAGNOSTICS.INLINE_FAILURE_TITLE'
|
||
);
|
||
});
|
||
|
||
it('renders technical details and codec-specific hints in the diagnostic banner', () => {
|
||
fixture.detectChanges();
|
||
const issue = createUnsupportedCodecDiagnostic();
|
||
|
||
component.handlePlaybackIssue(issue);
|
||
fixture.detectChanges();
|
||
|
||
const details = fixture.debugElement.query(
|
||
By.css('[data-test-id="playback-diagnostic-details"]')
|
||
);
|
||
const codecHint = fixture.debugElement.query(
|
||
By.css('[data-test-id="playback-codec-hint"]')
|
||
);
|
||
|
||
expect(details.nativeElement.textContent).toContain(
|
||
'PLAYBACK_DIAGNOSTICS.DETAILS_SUMMARY'
|
||
);
|
||
expect(component.getDiagnosticCodecHint(issue)).toBe('HEVC, AC-3');
|
||
expect(component.getDiagnosticDetails(issue)).toEqual(
|
||
expect.arrayContaining([
|
||
{
|
||
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_CODE',
|
||
value: 'unsupported-codec',
|
||
},
|
||
{
|
||
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_PLAYER',
|
||
value: 'Video.js',
|
||
},
|
||
{
|
||
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_VIDEO_CODECS',
|
||
value: 'hvc1.1.6.L93.B0',
|
||
},
|
||
{
|
||
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_AUDIO_CODECS',
|
||
value: 'ac-3',
|
||
},
|
||
])
|
||
);
|
||
expect(codecHint.nativeElement.textContent).toContain(
|
||
'PLAYBACK_DIAGNOSTICS.CODEC_HINT'
|
||
);
|
||
});
|
||
|
||
it('clears playback diagnostics when retrying inline playback', async () => {
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
fixture.detectChanges();
|
||
const player = fixture.debugElement.query(
|
||
By.directive(StubVjsPlayerComponent)
|
||
).componentInstance as StubVjsPlayerComponent;
|
||
expect(player.options()).toEqual(
|
||
expect.objectContaining({ reloadToken: 0 })
|
||
);
|
||
|
||
component.handlePlaybackIssue(createUnsupportedCodecDiagnostic());
|
||
fixture.detectChanges();
|
||
|
||
const retryButton = fixture.debugElement.query(
|
||
By.css('[data-test-id="playback-retry"]')
|
||
);
|
||
const utilityControls = fixture.nativeElement.querySelectorAll(
|
||
'[data-test-id="playback-retry"], [data-test-id="playback-diagnostic-details"]'
|
||
);
|
||
|
||
expect(utilityControls[0]).toBe(retryButton.nativeElement);
|
||
|
||
retryButton.nativeElement.click();
|
||
fixture.detectChanges();
|
||
|
||
expect(component.playbackDiagnostic()).toBeNull();
|
||
expect(player.options()).toEqual(
|
||
expect.objectContaining({ reloadToken: 1 })
|
||
);
|
||
expect(
|
||
fixture.debugElement.query(
|
||
By.css('[data-test-id="playback-diagnostic-banner"]')
|
||
)
|
||
).toBeNull();
|
||
});
|
||
});
|
||
|
||
function createUnsupportedContainerDiagnostic(): PlaybackDiagnostic {
|
||
return {
|
||
code: PlaybackDiagnosticCode.UnsupportedContainer,
|
||
source: PlaybackDiagnosticSource.Native,
|
||
sourceUrl: 'https://example.com/archive/movie.mkv',
|
||
container: 'mkv',
|
||
mimeType: 'video/matroska',
|
||
player: 'videojs',
|
||
audioCodecs: [],
|
||
videoCodecs: [],
|
||
externalFallbackRecommended: true,
|
||
};
|
||
}
|
||
|
||
function createBrowserAccessDiagnostic(): PlaybackDiagnostic {
|
||
return {
|
||
code: PlaybackDiagnosticCode.BrowserAccessError,
|
||
source: PlaybackDiagnosticSource.Hls,
|
||
sourceUrl: 'https://provider.example/live.m3u8',
|
||
container: 'm3u8',
|
||
mimeType: 'application/x-mpegURL',
|
||
player: 'videojs',
|
||
audioCodecs: [],
|
||
videoCodecs: [],
|
||
details: 'blocked by CORS policy',
|
||
externalFallbackRecommended: true,
|
||
};
|
||
}
|
||
|
||
function createUnsupportedCodecDiagnostic(): PlaybackDiagnostic {
|
||
return {
|
||
code: PlaybackDiagnosticCode.UnsupportedCodec,
|
||
source: PlaybackDiagnosticSource.Hls,
|
||
sourceUrl: 'https://example.com/live/index.m3u8',
|
||
container: 'm3u8',
|
||
mimeType: 'application/x-mpegURL',
|
||
player: 'videojs',
|
||
audioCodecs: ['ac-3'],
|
||
videoCodecs: ['hvc1.1.6.L93.B0'],
|
||
details: 'manifestIncompatibleCodecsError',
|
||
externalFallbackRecommended: true,
|
||
};
|
||
}
|
||
|
||
function createNetworkDiagnostic(): PlaybackDiagnostic {
|
||
return {
|
||
code: PlaybackDiagnosticCode.NetworkError,
|
||
source: PlaybackDiagnosticSource.MpegTs,
|
||
sourceUrl: 'https://example.com/live/channel.ts',
|
||
container: 'ts',
|
||
mimeType: 'video/mp2t',
|
||
player: 'videojs',
|
||
audioCodecs: [],
|
||
videoCodecs: [],
|
||
details: 'HttpStatusCodeInvalid {"code":456,"msg":"<none>"}',
|
||
externalFallbackRecommended: false,
|
||
};
|
||
}
|
||
|
||
function createHttpDiagnostic(): PlaybackDiagnostic {
|
||
return {
|
||
code: PlaybackDiagnosticCode.NetworkError,
|
||
source: PlaybackDiagnosticSource.Native,
|
||
sourceUrl: 'https://example.com/live/missing.m3u8',
|
||
container: 'm3u8',
|
||
mimeType: 'application/x-mpegURL',
|
||
player: 'videojs',
|
||
audioCodecs: [],
|
||
videoCodecs: [],
|
||
nativeErrorCode: 4,
|
||
nativeErrorMessage: 'source not supported',
|
||
httpStatus: 404,
|
||
nativeErrorType: 'networkrequestfailed',
|
||
externalFallbackRecommended: false,
|
||
};
|
||
}
|
||
|
||
function createStructuredHlsDiagnostic(): PlaybackDiagnostic {
|
||
return {
|
||
code: PlaybackDiagnosticCode.NetworkError,
|
||
source: PlaybackDiagnosticSource.Hls,
|
||
sourceUrl:
|
||
'https://provider.example/live.m3u8?token=diagnostic-url-secret',
|
||
container: 'm3u8',
|
||
mimeType: 'application/x-mpegURL',
|
||
player: 'html5',
|
||
audioCodecs: [],
|
||
videoCodecs: [],
|
||
httpStatus: 404,
|
||
hls: {
|
||
engineType: ErrorTypes.NETWORK_ERROR,
|
||
engineDetails: ErrorDetails.MANIFEST_LOAD_ERROR,
|
||
disposition: 'fatal',
|
||
stage: 'manifest',
|
||
failure: 'http',
|
||
httpStatus: 404,
|
||
},
|
||
externalFallbackRecommended: false,
|
||
};
|
||
}
|
||
|
||
function createStructuredVhsDiagnostic(): PlaybackDiagnostic {
|
||
return {
|
||
code: PlaybackDiagnosticCode.NetworkError,
|
||
source: PlaybackDiagnosticSource.Vhs,
|
||
sourceUrl: 'https://provider.example/live.m3u8?token=vhs-render-secret',
|
||
container: 'm3u8',
|
||
mimeType: 'application/x-mpegURL',
|
||
player: 'videojs',
|
||
audioCodecs: [],
|
||
videoCodecs: [],
|
||
details: 'Authorization response body vhs-render-secret',
|
||
nativeErrorCode: 4,
|
||
nativeErrorMessage:
|
||
'https://provider.example/error?token=vhs-render-secret',
|
||
httpStatus: 503,
|
||
vhs: {
|
||
engineType: 'networkbadstatus',
|
||
mediaErrorCode: 4,
|
||
disposition: 'terminal',
|
||
stage: 'unknown',
|
||
httpStatus: 503,
|
||
},
|
||
externalFallbackRecommended: false,
|
||
};
|
||
}
|