fix(playback): restore suppressed DASH captions when the preference re-enables

The Shaka bridge dropped the auto-selected text track with
selectTextTrack(null) when showCaptions was off, but did not remember it
— re-enabling the preference mid-session left captions permanently off
(HLS/native bridges already restore). The session now remembers the
suppressed track id and reselects it via the bridge's caption-state pass;
suppression is also skipped when no track is active. Covered by session
and new WebVideoShakaControls specs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-07-24 08:45:27 +02:00
1 parent 208d8d0fc2
commit 52ed64e45c
5 files changed
+167 -15

No files matched your search

@@ -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 {
@@ -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;
@@ -65,6 +65,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 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;
@@ -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);
});
});
@@ -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();
}
}