From 2c4887b489065963168889a19e06751e3e534bfd Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 24 Jul 2026 09:34:01 +0200 Subject: [PATCH] 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 --- .../html-video-player.component.dash.spec.ts | 81 +++++++++++++++++++ .../html-video-player.component.spec.ts | 29 ------- .../shaka-engine/shaka-error-classifier.ts | 23 ++++++ .../shaka-text-track-suppression.ts | 42 ++++++++++ .../lib/shaka-engine/shaka-video-session.ts | 55 +++---------- 5 files changed, 155 insertions(+), 75 deletions(-) create mode 100644 libs/ui/playback/src/lib/html-video-player/html-video-player.component.dash.spec.ts create mode 100644 libs/ui/playback/src/lib/shaka-engine/shaka-text-track-suppression.ts diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.dash.spec.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.dash.spec.ts new file mode 100644 index 000000000..02223c14e --- /dev/null +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.dash.spec.ts @@ -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; + 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); + }); +}); diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts index 4d79e1461..3c2477389 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts @@ -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; diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.ts b/libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.ts index 71b85b680..221384c31 100644 --- a/libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.ts +++ b/libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.ts @@ -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 | null { + if (!error || typeof error !== 'object') { + return null; + } + + const candidate = error as Partial; + 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 | null | undefined ): string { diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-text-track-suppression.ts b/libs/ui/playback/src/lib/shaka-engine/shaka-text-track-suppression.ts new file mode 100644 index 000000000..5d401819f --- /dev/null +++ b/libs/ui/playback/src/lib/shaka-engine/shaka-text-track-suppression.ts @@ -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; + } +} diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.ts b/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.ts index 24839c63e..6a25c597d 100644 --- a/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.ts +++ b/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.ts @@ -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 = Promise.resolve(); private pendingTeardown: Promise = 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 | null { - if (!error || typeof error !== 'object') { - return null; - } - - const candidate = error as Partial; - 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); -}