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:
4grayandClaude Fable 5 committed 2026-07-24 09:34:01 +02:00
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);
}