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);
-}