mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 12:06:16 -08:00
refactor(playback): split oversized Shaka session and HTML5 spec files
CI lint enforces max-lines 400: extract ShakaTextTrackSuppression and the shaka-error helpers out of ShakaVideoSession, and move the DASH-specific HTML5 player test into its own spec. No behavior change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d3fa30ff87
commit
2c4887b489
5 files changed
+155
-75
No files matched your search
@@ -0,0 +1,81 @@
|
||||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { DataService } from '@iptvnator/services';
|
||||
import { Channel } from '@iptvnator/shared/interfaces';
|
||||
import { HtmlVideoPlayerComponent } from './html-video-player.component';
|
||||
|
||||
/** DASH-specific behavior of the HTML5 player (legacy controls mode). */
|
||||
describe('HtmlVideoPlayerComponent DASH', () => {
|
||||
let component: HtmlVideoPlayerComponent;
|
||||
let fixture: ComponentFixture<HtmlVideoPlayerComponent>;
|
||||
const electronApi = {
|
||||
setUserAgent: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
|
||||
const TEST_CHANNEL: Channel = {
|
||||
id: 'dash-1',
|
||||
url: 'https://stream.example/enc.mpd',
|
||||
name: 'DASH channel',
|
||||
group: { title: 'DASH' },
|
||||
http: { origin: '', referrer: '', 'user-agent': '' },
|
||||
radio: 'false',
|
||||
tvg: { id: '', logo: '', name: '', rec: '', url: '' },
|
||||
};
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HtmlVideoPlayerComponent, TranslateModule.forRoot()],
|
||||
providers: [
|
||||
{
|
||||
provide: DataService,
|
||||
useValue: {
|
||||
sendIpcEvent: jest.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, 'electron', {
|
||||
configurable: true,
|
||||
value: electronApi,
|
||||
});
|
||||
fixture = TestBed.createComponent(HtmlVideoPlayerComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (window as unknown as { electron?: unknown }).electron;
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('does not resume the previous source when a channel declares unsupported DRM', () => {
|
||||
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/movie.mp4',
|
||||
});
|
||||
playSpy.mockClear();
|
||||
loadSpy.mockClear();
|
||||
|
||||
component.playChannel({
|
||||
...TEST_CHANNEL,
|
||||
drm: {
|
||||
licenseType: 'com.widevine.alpha',
|
||||
supported: false,
|
||||
},
|
||||
});
|
||||
|
||||
// No source is loaded for unsupported DRM: play() must not run, and
|
||||
// the element is reset so the old stream cannot keep playing.
|
||||
expect(playSpy).not.toHaveBeenCalled();
|
||||
expect(loadSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -224,35 +224,6 @@ describe('HtmlVideoPlayerComponent', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('does not resume the previous source when a channel declares unsupported DRM', () => {
|
||||
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/movie.mp4',
|
||||
});
|
||||
playSpy.mockClear();
|
||||
loadSpy.mockClear();
|
||||
|
||||
component.playChannel({
|
||||
...TEST_CHANNEL,
|
||||
url: 'https://stream.example/enc.mpd',
|
||||
drm: {
|
||||
licenseType: 'com.widevine.alpha',
|
||||
supported: false,
|
||||
},
|
||||
});
|
||||
|
||||
// No source is loaded for unsupported DRM: play() must not run, and
|
||||
// the element is reset so the old stream cannot keep playing.
|
||||
expect(playSpy).not.toHaveBeenCalled();
|
||||
expect(loadSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('emits a playback issue when the native video element reports an unsupported source', () => {
|
||||
const issues: unknown[] = [];
|
||||
component.channel = TEST_CHANNEL;
|
||||
|
||||
@@ -117,6 +117,29 @@ export function createUnsupportedDrmDiagnostic(
|
||||
});
|
||||
}
|
||||
|
||||
/** Narrows an unknown rejection to a Shaka-error-like shape, if it is one. */
|
||||
export function asShakaError(
|
||||
error: unknown
|
||||
): Partial<ShakaErrorLike> | null {
|
||||
if (!error || typeof error !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = error as Partial<ShakaErrorLike>;
|
||||
return typeof candidate.code === 'number' ||
|
||||
typeof candidate.category === 'number'
|
||||
? candidate
|
||||
: null;
|
||||
}
|
||||
|
||||
export function toErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return typeof error === 'string' ? error : String(error);
|
||||
}
|
||||
|
||||
function formatShakaErrorDetails(
|
||||
error: Partial<ShakaErrorLike> | null | undefined
|
||||
): string {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ShakaPlayerLike } from './shaka-module.types';
|
||||
|
||||
/**
|
||||
* Remembers the text track hidden while the captions preference is off so it
|
||||
* can be reselected when the preference turns back on — Shaka 5 has no
|
||||
* separate visibility API (selecting a track shows it, `null` hides text).
|
||||
*/
|
||||
export class ShakaTextTrackSuppression {
|
||||
private suppressedTrackId: number | null = null;
|
||||
|
||||
/** Hides the active text track, remembering it for {@link restore}. */
|
||||
suppress(player: ShakaPlayerLike): void {
|
||||
const active = player
|
||||
.getTextTracks()
|
||||
.find((candidate) => candidate.active);
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.suppressedTrackId = active.id;
|
||||
player.selectTextTrack(null);
|
||||
}
|
||||
|
||||
/** One-shot reselection of the previously suppressed track. */
|
||||
restore(player: ShakaPlayerLike): void {
|
||||
if (this.suppressedTrackId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const track = player
|
||||
.getTextTracks()
|
||||
.find((candidate) => candidate.id === this.suppressedTrackId);
|
||||
this.suppressedTrackId = null;
|
||||
if (track && !track.active) {
|
||||
player.selectTextTrack(track);
|
||||
}
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.suppressedTrackId = null;
|
||||
}
|
||||
}
|
||||
@@ -11,9 +11,12 @@ import {
|
||||
createPlaybackSourceMetadata,
|
||||
} from '../playback-diagnostics/playback-diagnostics.util';
|
||||
import {
|
||||
asShakaError,
|
||||
classifyShakaPlaybackIssue,
|
||||
createUnsupportedDrmDiagnostic,
|
||||
toErrorMessage,
|
||||
} from './shaka-error-classifier';
|
||||
import { ShakaTextTrackSuppression } from './shaka-text-track-suppression';
|
||||
import {
|
||||
loadShakaModule,
|
||||
type ShakaErrorLike,
|
||||
@@ -65,7 +68,7 @@ export class ShakaVideoSession {
|
||||
private readonly refreshListeners = new Set<() => void>();
|
||||
private operationChain: Promise<void> = Promise.resolve();
|
||||
private pendingTeardown: Promise<void> = Promise.resolve();
|
||||
private suppressedTextTrackId: number | null = null;
|
||||
private readonly textSuppression = new ShakaTextTrackSuppression();
|
||||
private generation = 0;
|
||||
private destroyed = false;
|
||||
|
||||
@@ -193,7 +196,7 @@ export class ShakaVideoSession {
|
||||
// Shaka 5 shows a text track by selecting it; keep captions off when
|
||||
// the preference is disabled in case the manifest auto-selected one.
|
||||
if (!(this.config.showCaptions?.() ?? false)) {
|
||||
this.suppressTextTracksOn(player);
|
||||
this.textSuppression.suppress(player);
|
||||
}
|
||||
this.notifyRefresh();
|
||||
}
|
||||
@@ -202,36 +205,16 @@ export class ShakaVideoSession {
|
||||
suppressTextTracks(): void {
|
||||
const player = this.player;
|
||||
if (player) {
|
||||
this.suppressTextTracksOn(player);
|
||||
this.textSuppression.suppress(player);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reselects the text track hidden by {@link suppressTextTracks}. */
|
||||
restoreSuppressedTextTrack(): void {
|
||||
const player = this.player;
|
||||
if (!player || this.suppressedTextTrackId === null) {
|
||||
return;
|
||||
if (player) {
|
||||
this.textSuppression.restore(player);
|
||||
}
|
||||
|
||||
const track = player
|
||||
.getTextTracks()
|
||||
.find((candidate) => candidate.id === this.suppressedTextTrackId);
|
||||
this.suppressedTextTrackId = null;
|
||||
if (track && !track.active) {
|
||||
player.selectTextTrack(track);
|
||||
}
|
||||
}
|
||||
|
||||
private suppressTextTracksOn(player: ShakaPlayerLike): void {
|
||||
const active = player
|
||||
.getTextTracks()
|
||||
.find((candidate) => candidate.active);
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.suppressedTextTrackId = active.id;
|
||||
player.selectTextTrack(null);
|
||||
}
|
||||
|
||||
private handleLoadFailure(
|
||||
@@ -334,7 +317,7 @@ export class ShakaVideoSession {
|
||||
private beginPlayerTeardown(): void {
|
||||
const player = this.player;
|
||||
this.player = null;
|
||||
this.suppressedTextTrackId = null;
|
||||
this.textSuppression.reset();
|
||||
if (!player) {
|
||||
this.playerErrorListener = null;
|
||||
this.playerRefreshListener = null;
|
||||
@@ -403,23 +386,3 @@ export class ShakaVideoSession {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function asShakaError(error: unknown): Partial<ShakaErrorLike> | null {
|
||||
if (!error || typeof error !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = error as Partial<ShakaErrorLike>;
|
||||
return typeof candidate.code === 'number' ||
|
||||
typeof candidate.category === 'number'
|
||||
? candidate
|
||||
: null;
|
||||
}
|
||||
|
||||
function toErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return typeof error === 'string' ? error : String(error);
|
||||
}
|
||||
Reference in new issue
Block a user