mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 22:26:16 -08:00
feat(html-player): add feature-flagged shared controls (#1194)
* feat(html-player): bridge engine state to shared controls * fix(html-player): avoid HLS subtitle event reentry * fix(html-player): restore delayed HLS default subtitles * refactor(html-player): split controls bridge collaborators * feat(html-player): add feature-flagged shared controls * test(html-player): cover shared-controls source ownership * feat(html-player): pass shared-controls playback metadata * docs(player-controls): describe HTML5 shared-controls bridge * docs(player-controls): clarify HTML5 rollout effect * fix(html-player): reveal diagnostics from fullscreen * fix(html-player): defer HLS event resolution * refactor(html-player): isolate video element session
This commit is contained in:
1 parent
f611ea3d7c
commit
c49ea2f6a8
29 files changed
+4185
-236
No files matched your search
@@ -20,6 +20,8 @@
|
||||
[channel]="$any(channel)"
|
||||
[volume]="volume()"
|
||||
[showCaptions]="showCaptions()"
|
||||
[isLive]="resolvedIsLive()"
|
||||
[interactionEnabled]="playbackInteractionEnabled()"
|
||||
[startTime]="startTime()"
|
||||
[seriesNavigation]="seriesNavigation()"
|
||||
(timeUpdate)="timeUpdate.emit($event)"
|
||||
|
||||
+276
@@ -0,0 +1,276 @@
|
||||
import { ClipboardModule } from '@angular/cdk/clipboard';
|
||||
import { Component, input, output } from '@angular/core';
|
||||
import {
|
||||
ComponentFixture,
|
||||
DeferBlockBehavior,
|
||||
TestBed,
|
||||
} from '@angular/core/testing';
|
||||
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 { StorageMap } from '@ngx-pwa/local-storage';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { of } from 'rxjs';
|
||||
import {
|
||||
type ResolvedPortalPlayback,
|
||||
VideoPlayer,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { RuntimeCapabilitiesService } from '@iptvnator/services';
|
||||
import type { WebPlayerViewComponent as WebPlayerViewComponentInstance } from './web-player-view.component';
|
||||
import {
|
||||
type 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: '' })
|
||||
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({ selector: 'app-html-video-player', template: '' })
|
||||
class StubHtmlVideoPlayerComponent {
|
||||
readonly channel = input<unknown>();
|
||||
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: '' })
|
||||
class StubArtPlayerComponent {
|
||||
readonly channel = input<unknown>();
|
||||
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({ selector: 'app-embedded-mpv-player', template: '' })
|
||||
class StubEmbeddedMpvPlayerComponent {
|
||||
readonly playback = input.required<unknown>();
|
||||
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 shared HTML5 controls metadata', () => {
|
||||
let WebPlayerViewComponent: typeof import('./web-player-view.component').WebPlayerViewComponent;
|
||||
let fixture: ComponentFixture<WebPlayerViewComponentInstance>;
|
||||
let component: WebPlayerViewComponentInstance;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ WebPlayerViewComponent } =
|
||||
await import('./web-player-view.component'));
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
deferBlockBehavior: DeferBlockBehavior.Playthrough,
|
||||
imports: [WebPlayerViewComponent, TranslateModule.forRoot()],
|
||||
providers: [
|
||||
{
|
||||
provide: StorageMap,
|
||||
useValue: {
|
||||
get: jest.fn(() => of({ player: VideoPlayer.VideoJs })),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: RuntimeCapabilitiesService,
|
||||
useValue: { supportsManagedExternalPlayers: false },
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideComponent(WebPlayerViewComponent, {
|
||||
set: {
|
||||
imports: [
|
||||
StubArtPlayerComponent,
|
||||
StubEmbeddedMpvPlayerComponent,
|
||||
StubHtmlVideoPlayerComponent,
|
||||
StubVjsPlayerComponent,
|
||||
ClipboardModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatTooltipModule,
|
||||
TranslateModule,
|
||||
],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(WebPlayerViewComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.componentRef.setInput(
|
||||
'streamUrl',
|
||||
'https://example.com/default.ts'
|
||||
);
|
||||
fixture.componentRef.setInput('title', 'Default stream');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture.destroy();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an explicit VOD value', { isLive: false }, false],
|
||||
[
|
||||
'an explicit live value with VOD content metadata',
|
||||
{ isLive: true, contentInfo: createVodContentInfo() },
|
||||
true,
|
||||
],
|
||||
[
|
||||
'VOD content metadata without an explicit value',
|
||||
{ contentInfo: createVodContentInfo() },
|
||||
false,
|
||||
],
|
||||
['missing content metadata and explicit value', {}, true],
|
||||
])('resolves %s', (_label, metadata, expected) => {
|
||||
setPlayback(metadata);
|
||||
|
||||
expect(component.resolvedIsLive()).toBe(expected);
|
||||
});
|
||||
|
||||
it('passes the resolved live value to the HTML5 player', async () => {
|
||||
const htmlPlayer = await renderHtmlPlayer({ isLive: false });
|
||||
|
||||
expect(htmlPlayer.isLive()).toBe(false);
|
||||
});
|
||||
|
||||
it('disables HTML5 surface interaction while a diagnostic is visible', async () => {
|
||||
const htmlPlayer = await renderHtmlPlayer();
|
||||
|
||||
component.handlePlaybackIssue(createNetworkDiagnostic());
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.playbackInteractionEnabled()).toBe(false);
|
||||
expect(htmlPlayer.interactionEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('re-enables HTML5 interaction after retrying or clearing the issue', async () => {
|
||||
const htmlPlayer = await renderHtmlPlayer();
|
||||
|
||||
component.handlePlaybackIssue(createNetworkDiagnostic());
|
||||
fixture.detectChanges();
|
||||
expect(htmlPlayer.interactionEnabled()).toBe(false);
|
||||
|
||||
component.retryPlayback();
|
||||
fixture.detectChanges();
|
||||
expect(component.playbackInteractionEnabled()).toBe(true);
|
||||
expect(htmlPlayer.interactionEnabled()).toBe(true);
|
||||
|
||||
component.handlePlaybackIssue(createNetworkDiagnostic());
|
||||
fixture.detectChanges();
|
||||
expect(htmlPlayer.interactionEnabled()).toBe(false);
|
||||
|
||||
component.handlePlaybackIssue(null);
|
||||
fixture.detectChanges();
|
||||
expect(component.playbackInteractionEnabled()).toBe(true);
|
||||
expect(htmlPlayer.interactionEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['inferred VOD', { contentInfo: createVodContentInfo() }, false],
|
||||
[
|
||||
'explicit live VOD content',
|
||||
{ isLive: true, contentInfo: createVodContentInfo() },
|
||||
true,
|
||||
],
|
||||
])(
|
||||
'uses the resolved value for Video.js effect and retry: %s',
|
||||
(_label, metadata, expected) => {
|
||||
const streamUrl = 'https://example.com/video.ts';
|
||||
const setVjsOptions = jest.spyOn(component, 'setVjsOptions');
|
||||
setPlayback(metadata, streamUrl);
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(setVjsOptions).toHaveBeenCalledWith(streamUrl, expected);
|
||||
|
||||
setVjsOptions.mockClear();
|
||||
component.retryPlayback();
|
||||
|
||||
expect(setVjsOptions).toHaveBeenCalledWith(streamUrl, expected);
|
||||
}
|
||||
);
|
||||
|
||||
function setPlayback(
|
||||
metadata: Partial<ResolvedPortalPlayback>,
|
||||
streamUrl = 'https://example.com/playback.ts'
|
||||
): void {
|
||||
fixture.componentRef.setInput('playback', {
|
||||
streamUrl,
|
||||
title: 'Playback',
|
||||
...metadata,
|
||||
});
|
||||
}
|
||||
|
||||
async function renderHtmlPlayer(
|
||||
metadata: Partial<ResolvedPortalPlayback> = {}
|
||||
): Promise<StubHtmlVideoPlayerComponent> {
|
||||
fixture.componentRef.setInput(
|
||||
'playerOverride',
|
||||
VideoPlayer.Html5Player
|
||||
);
|
||||
setPlayback(metadata);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
return fixture.debugElement.query(
|
||||
By.directive(StubHtmlVideoPlayerComponent)
|
||||
).componentInstance as StubHtmlVideoPlayerComponent;
|
||||
}
|
||||
});
|
||||
|
||||
function createVodContentInfo() {
|
||||
return {
|
||||
playlistId: 'playlist-1',
|
||||
contentXtreamId: 123,
|
||||
contentType: 'vod' as const,
|
||||
};
|
||||
}
|
||||
|
||||
function createNetworkDiagnostic(): PlaybackDiagnostic {
|
||||
return {
|
||||
code: PlaybackDiagnosticCode.NetworkError,
|
||||
source: PlaybackDiagnosticSource.MpegTs,
|
||||
sourceUrl: 'https://example.com/playback.ts',
|
||||
container: 'ts',
|
||||
mimeType: 'video/mp2t',
|
||||
player: 'html5',
|
||||
audioCodecs: [],
|
||||
videoCodecs: [],
|
||||
externalFallbackRecommended: false,
|
||||
};
|
||||
}
|
||||
@@ -53,6 +53,8 @@ class StubHtmlVideoPlayerComponent {
|
||||
readonly channel = input<unknown>();
|
||||
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 }>();
|
||||
|
||||
@@ -102,6 +102,9 @@ export class WebPlayerViewComponent {
|
||||
? null
|
||||
: this.playbackDiagnostic()
|
||||
);
|
||||
readonly playbackInteractionEnabled = computed(
|
||||
() => this.visiblePlaybackDiagnostic() === null
|
||||
);
|
||||
readonly canShowExternalFallbackActions = computed(
|
||||
() =>
|
||||
this.runtime.supportsManagedExternalPlayers &&
|
||||
@@ -125,6 +128,12 @@ export class WebPlayerViewComponent {
|
||||
startTime: this.startTime(),
|
||||
};
|
||||
});
|
||||
readonly resolvedIsLive = computed(() => {
|
||||
const playback = this.resolvedPlayback();
|
||||
return typeof playback.isLive === 'boolean'
|
||||
? playback.isLive
|
||||
: !playback.contentInfo;
|
||||
});
|
||||
readonly selectedPlayer = computed(
|
||||
() =>
|
||||
this.playerOverride() ??
|
||||
@@ -143,10 +152,7 @@ export class WebPlayerViewComponent {
|
||||
const playback = this.resolvedPlayback();
|
||||
this.playbackDiagnostic.set(null);
|
||||
this.setChannel(playback);
|
||||
this.setVjsOptions(
|
||||
playback.streamUrl,
|
||||
this.isLivePlayback(playback)
|
||||
);
|
||||
this.setVjsOptions(playback.streamUrl, this.resolvedIsLive());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -233,7 +239,7 @@ export class WebPlayerViewComponent {
|
||||
this.playbackDiagnostic.set(null);
|
||||
this.reloadToken.update((value) => value + 1);
|
||||
this.setChannel(playback);
|
||||
this.setVjsOptions(playback.streamUrl, this.isLivePlayback(playback));
|
||||
this.setVjsOptions(playback.streamUrl, this.resolvedIsLive());
|
||||
}
|
||||
|
||||
getDiagnosticTitleKey(issue: PlaybackDiagnostic): string {
|
||||
@@ -331,14 +337,6 @@ export class WebPlayerViewComponent {
|
||||
}
|
||||
}
|
||||
|
||||
private isLivePlayback(playback: ResolvedPortalPlayback): boolean {
|
||||
if (typeof playback.isLive === 'boolean') {
|
||||
return playback.isLive;
|
||||
}
|
||||
|
||||
return !playback.contentInfo;
|
||||
}
|
||||
|
||||
private getHeaderValue(
|
||||
headers: ResolvedPortalPlayback['headers'] | undefined,
|
||||
name: string
|
||||
|
||||
Reference in new issue
Block a user