diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-player-test-double.ts b/libs/ui/playback/src/lib/shaka-engine/shaka-player-test-double.ts index 86a64b679..eb6aed50e 100644 --- a/libs/ui/playback/src/lib/shaka-engine/shaka-player-test-double.ts +++ b/libs/ui/playback/src/lib/shaka-engine/shaka-player-test-double.ts @@ -94,6 +94,9 @@ export class FakeShakaPlayer implements ShakaPlayerLike { selectTextTrack(track: ShakaTextTrackLike | null): void { this.selectTextTrackCalls.push(track); + for (const candidate of this.textTracks) { + candidate.active = candidate === track; + } } isLive(): boolean { diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.spec.ts b/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.spec.ts index e0c1b1379..a568e6f66 100644 --- a/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.spec.ts +++ b/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.spec.ts @@ -226,13 +226,44 @@ describe('ShakaVideoSession', () => { ]; expect(slow.destroyCount).toBe(1); expect(fast.loadedUrls).toEqual(['http://example.com/fast.mpd']); - // The slow player resolved after being superseded; its post-load - // caption suppression must not have been applied. + // The slow player resolved after being superseded; nothing may have + // touched its text tracks afterwards. expect(slow.selectTextTrackCalls).toEqual([]); - expect(fast.selectTextTrackCalls).toEqual([null]); expect(issues).toEqual([]); }); + it('suppresses auto-selected captions on load and restores them on demand', async () => { + const environment = createFakeShakaEnvironment({ + onCreate: (player) => { + player.textTracks = [ + { + id: 7, + active: true, + language: 'en', + label: 'English', + kind: 'subtitles', + }, + ]; + }, + }); + const session = createSession(environment); + session.start(video, 'http://example.com/subs.mpd'); + await flush(); + + const player = environment.instances[0]; + // showCaptions() is false: the auto-selected track is hidden… + expect(player.selectTextTrackCalls).toEqual([null]); + expect(player.textTracks[0].active).toBe(false); + + // …and remembered, so re-enabling the preference restores it. + session.restoreSuppressedTextTrack(); + expect(player.textTracks[0].active).toBe(true); + + // The restore is one-shot. + session.restoreSuppressedTextTrack(); + expect(player.selectTextTrackCalls).toHaveLength(2); + }); + it('emits an unsupported-container diagnostic when the browser lacks MSE/EME', async () => { const environment = createFakeShakaEnvironment(); environment.browserSupported = false; 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 c7696023b..24839c63e 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 @@ -65,6 +65,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 generation = 0; private destroyed = false; @@ -192,11 +193,47 @@ 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)) { - player.selectTextTrack(null); + this.suppressTextTracksOn(player); } this.notifyRefresh(); } + /** Hides the active text track, remembering it for a later restore. */ + suppressTextTracks(): void { + const player = this.player; + if (player) { + this.suppressTextTracksOn(player); + } + } + + /** Reselects the text track hidden by {@link suppressTextTracks}. */ + restoreSuppressedTextTrack(): void { + const player = this.player; + if (!player || this.suppressedTextTrackId === null) { + return; + } + + 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( generation: number, player: ShakaPlayerLike, @@ -297,6 +334,7 @@ export class ShakaVideoSession { private beginPlayerTeardown(): void { const player = this.player; this.player = null; + this.suppressedTextTrackId = null; if (!player) { this.playerErrorListener = null; this.playerRefreshListener = null; diff --git a/libs/ui/playback/src/lib/web-video-support/web-video-shaka-controls.spec.ts b/libs/ui/playback/src/lib/web-video-support/web-video-shaka-controls.spec.ts new file mode 100644 index 000000000..a59da9f04 --- /dev/null +++ b/libs/ui/playback/src/lib/web-video-support/web-video-shaka-controls.spec.ts @@ -0,0 +1,81 @@ +import { InlinePlaybackPlayer } from '../playback-diagnostics/playback-diagnostics.model'; +import { + createFakeShakaEnvironment, + flushShakaMicrotasks as flush, +} from '../shaka-engine/shaka-player-test-double'; +import { ShakaVideoSession } from '../shaka-engine/shaka-video-session'; +import { WebVideoShakaControls } from './web-video-shaka-controls'; + +describe('WebVideoShakaControls', () => { + const video = {} as HTMLVideoElement; + + function createHarness() { + const environment = createFakeShakaEnvironment({ + onCreate: (player) => { + player.textTracks = [ + { + id: 3, + active: true, + language: 'en', + label: 'English', + kind: 'subtitles', + }, + ]; + }, + }); + const state = { showCaptions: false }; + const session = new ShakaVideoSession({ + player: InlinePlaybackPlayer.Html5, + emitPlaybackIssue: () => undefined, + showCaptions: () => state.showCaptions, + loadShaka: environment.loader, + }); + const controls = new WebVideoShakaControls({ + showCaptions: () => state.showCaptions, + refresh: () => undefined, + }); + return { environment, session, controls, state }; + } + + it('restores the suppressed caption track when the preference turns on', async () => { + const { environment, session, controls, state } = createHarness(); + controls.bind(session); + session.start(video, 'http://example.com/subs.mpd'); + await flush(); + + const player = environment.instances[0]; + expect(player.textTracks[0].active).toBe(false); + + state.showCaptions = true; + controls.refreshInputs(); + + expect(player.textTracks[0].active).toBe(true); + }); + + it('keeps an explicit user subtitle-off choice over the preference', async () => { + const { environment, session, controls, state } = createHarness(); + controls.bind(session); + session.start(video, 'http://example.com/subs.mpd'); + await flush(); + + controls.setSubtitleTrack(-1); + state.showCaptions = true; + controls.refreshInputs(); + + const player = environment.instances[0]; + expect(player.textTracks[0].active).toBe(false); + }); + + it('lists subtitle tracks with the active flag as selection state', async () => { + const { environment, session, controls, state } = createHarness(); + state.showCaptions = true; + controls.bind(session); + session.start(video, 'http://example.com/subs.mpd'); + await flush(); + + expect(controls.getSubtitleTracks()).toEqual([ + { id: 0, label: 'English', selected: true }, + ]); + expect(environment.instances[0].textTracks[0].active).toBe(true); + }); +}); diff --git a/libs/ui/playback/src/lib/web-video-support/web-video-shaka-controls.ts b/libs/ui/playback/src/lib/web-video-support/web-video-shaka-controls.ts index f3b08d7b0..d66173bd4 100644 --- a/libs/ui/playback/src/lib/web-video-support/web-video-shaka-controls.ts +++ b/libs/ui/playback/src/lib/web-video-support/web-video-shaka-controls.ts @@ -106,20 +106,19 @@ export class WebVideoShakaControls { } private applyCaptionState(): void { - const player = this.getPlayer(); - if (!player) { + const session = this.session; + if (!session || this.subtitleOverride !== null) { + // A user selection (subtitleOverride) always wins. return; } - // Without an explicit user choice, keep manifest-auto-selected text - // hidden while the captions preference is off (HLS parity). A user - // selection (subtitleOverride) always wins. - if ( - this.subtitleOverride === null && - !this.config.showCaptions() && - player.getTextTracks().some((track) => track.active) - ) { - player.selectTextTrack(null); + // Without an explicit user choice, mirror the HLS bridge: keep + // manifest-auto-selected text hidden while the captions preference is + // off, and reselect the suppressed track when it turns back on. + if (this.config.showCaptions()) { + session.restoreSuppressedTextTrack(); + } else { + session.suppressTextTracks(); } }