fix: textTrackList and optimize (#760)

This commit is contained in:
Wesley Luyten
2026-03-06 14:10:42 -06:00
committed by GitHub
parent bcc492f63f
commit 04e98f4007
7 changed files with 40 additions and 64 deletions
+2 -2
View File
@@ -235,8 +235,8 @@ export interface MediaTextTrackState {
thumbnailCues: MediaTextCue[];
/** The `<track>` element's `src` for resolving relative cue text URLs. */
thumbnailTrackSrc: string | null;
/** Caption/subtitle tracks that can be selected or toggled. */
subtitlesList: MediaTextTrack<'subtitles' | 'captions'>[];
/** All text tracks available on the media element. */
textTrackList: MediaTextTrack<TextTrackKind>[];
/** Whether captions/subtitles are currently enabled. */
subtitlesShowing: boolean;
/** Toggle captions/subtitles visibility. Returns the new enabled value. */
@@ -60,7 +60,9 @@ export class CaptionsButtonCore {
const media = this.#media!;
return {
subtitlesShowing: media.subtitlesShowing,
availability: media.subtitlesList.length > 0 ? 'available' : 'unavailable',
availability: media.textTrackList.some((t) => t.kind === 'captions' || t.kind === 'subtitles')
? 'available'
: 'unavailable',
};
}
@@ -9,7 +9,7 @@ function createMediaState(overrides: Partial<MediaTextTrackState> = {}): MediaTe
chaptersCues: [],
thumbnailCues: [],
thumbnailTrackSrc: null,
subtitlesList: [],
textTrackList: [],
subtitlesShowing: false,
toggleSubtitles: vi.fn(() => true),
...overrides,
@@ -30,7 +30,7 @@ describe('CaptionsButtonCore', () => {
const core = new CaptionsButtonCore();
const media = createMediaState({
subtitlesShowing: true,
subtitlesList: [{ kind: 'subtitles', label: 'English', language: 'en', mode: 'showing' }],
textTrackList: [{ kind: 'subtitles', label: 'English', language: 'en', mode: 'showing' }],
});
core.setMedia(media);
const state = core.getState();
@@ -42,7 +42,10 @@ describe('CaptionsButtonCore', () => {
const core = new CaptionsButtonCore();
core.setMedia(
createMediaState({
subtitlesList: [{ kind: 'subtitles', label: 'English', language: 'en', mode: 'disabled' }],
textTrackList: [
{ kind: 'subtitles', label: 'English', language: 'en', mode: 'disabled' },
{ kind: 'metadata', label: 'thumbnails', language: '', mode: 'hidden' },
],
})
);
@@ -51,7 +54,9 @@ describe('CaptionsButtonCore', () => {
it('returns unavailable when no subtitles', () => {
const core = new CaptionsButtonCore();
core.setMedia(createMediaState({ subtitlesList: [] }));
core.setMedia(
createMediaState({ textTrackList: [{ kind: 'metadata', label: 'thumbnails', language: '', mode: 'hidden' }] })
);
expect(core.getState().availability).toBe('unavailable');
});
@@ -43,7 +43,7 @@ describe('textTrackFeature', () => {
expect(store.state.chaptersCues).toEqual([]);
expect(store.state.thumbnailCues).toEqual([]);
expect(store.state.thumbnailTrackSrc).toBeNull();
expect(store.state.subtitlesList).toEqual([]);
expect(store.state.textTrackList).toEqual([]);
expect(store.state.subtitlesShowing).toBe(false);
});
});
@@ -149,18 +149,20 @@ describe('textTrackFeature', () => {
expect(store.state.subtitlesShowing).toBe(true);
});
it('exposes subtitlesList from captions/subtitles tracks', () => {
it('exposes textTrackList for all track kinds', () => {
const video = createVideo();
const subtitlesTrack = { kind: 'subtitles', mode: 'showing', label: 'English', language: 'en' } as TextTrack;
const captionsTrack = { kind: 'captions', mode: 'disabled', label: 'CC', language: 'en' } as TextTrack;
mockTextTracks(video, [subtitlesTrack, captionsTrack, createMockTrack('metadata', 'showing')]);
const metadataTrack = createMockTrack('metadata', 'showing');
mockTextTracks(video, [subtitlesTrack, captionsTrack, metadataTrack]);
const store = createStore<PlayerTarget>()(textTrackFeature);
store.attach({ media: video, container: null });
expect(store.state.subtitlesList).toEqual([
expect(store.state.textTrackList).toEqual([
{ kind: 'subtitles', label: 'English', language: 'en', mode: 'showing' },
{ kind: 'captions', label: 'CC', language: 'en', mode: 'disabled' },
{ kind: 'metadata', label: '', language: '', mode: 'showing' },
]);
});
@@ -1,4 +1,4 @@
import { findTrackElement, getSubtitlesTracks, listen } from '@videojs/utils/dom';
import { findTrackElement, getTextTrackList, listen } from '@videojs/utils/dom';
import type { MediaTextCue, MediaTextTrack, MediaTextTrackState } from '../../../core/media/state';
import { definePlayerFeature } from '../../feature';
@@ -9,10 +9,13 @@ export const textTrackFeature = definePlayerFeature({
chaptersCues: [],
thumbnailCues: [],
thumbnailTrackSrc: null,
subtitlesList: [],
textTrackList: [],
subtitlesShowing: false,
toggleSubtitles(forceShow?: boolean) {
const subtitlesTracks = getSubtitlesTracks(target().media);
const subtitlesTracks = getTextTrackList(
target().media,
(track) => track.kind === 'subtitles' || track.kind === 'captions'
);
if (!subtitlesTracks.length) return false;
const showing = subtitlesTracks.some((track: TextTrack) => track.mode === 'showing');
@@ -37,25 +40,23 @@ export const textTrackFeature = definePlayerFeature({
let chaptersTrack: TextTrack | null = null;
let thumbnailTrack: TextTrack | null = null;
const subtitlesList: MediaTextTrack<'subtitles' | 'captions'>[] = [];
const textTrackList: MediaTextTrack<TextTrackKind>[] = [];
let subtitlesShowing = false;
for (let i = 0; i < media.textTracks.length; i++) {
const track = media.textTracks[i]!;
if (!chaptersTrack && track.kind === 'chapters') chaptersTrack = track;
if (!thumbnailTrack && track.kind === 'metadata' && track.label === 'thumbnails') thumbnailTrack = track;
if (track.kind === 'captions' || track.kind === 'subtitles') {
const showing = track.mode === 'showing';
subtitlesList.push({
kind: track.kind,
label: track.label,
language: track.language,
mode: track.mode,
});
if (showing) {
subtitlesShowing = true;
}
textTrackList.push({
kind: track.kind,
label: track.label,
language: track.language,
mode: track.mode,
});
if ((track.kind === 'captions' || track.kind === 'subtitles') && track.mode === 'showing') {
subtitlesShowing = true;
}
}
@@ -83,7 +84,7 @@ export const textTrackFeature = definePlayerFeature({
}
}
set({ chaptersCues, thumbnailCues, thumbnailTrackSrc, subtitlesList, subtitlesShowing });
set({ chaptersCues, thumbnailCues, thumbnailTrackSrc, textTrackList, subtitlesShowing });
}
sync();
+1 -1
View File
@@ -14,6 +14,6 @@ export {
supportsIdleCallback,
supportsPopoverAPI,
} from './supports';
export { findTrackElement, getSubtitlesTracks, getTextTracksList } from './text-track';
export { findTrackElement, getTextTrackList } from './text-track';
export { serializeTimeRanges } from './time-ranges';
export type { CustomElement, CustomElementCallbacks } from './types';
+2 -36
View File
@@ -6,45 +6,11 @@ export function findTrackElement(media: HTMLMediaElement, track: TextTrack): HTM
return null;
}
export function getSubtitlesTracks(media: HTMLMediaElement): TextTrack[] {
return getTextTracksList(media, isSubtitleTrack).sort(sortByTextTrackKind);
}
export function getTextTracksList(
media: HTMLMediaElement,
filterPredOrObj: ((textTrack: TextTrack) => boolean) | TextTrack = alwaysTrue
): TextTrack[] {
export function getTextTrackList(media: HTMLMediaElement, filterPred: (textTrack: TextTrack) => boolean): TextTrack[] {
if (!media?.textTracks) return [];
const filterPred = typeof filterPredOrObj === 'function' ? filterPredOrObj : textTrackObjAsPred(filterPredOrObj);
return (Array.from(media.textTracks) as TextTrack[]).filter(filterPred);
}
export function textTrackObjAsPred(filterObj: any): (textTrack: TextTrack) => boolean {
const preds = Object.entries(filterObj).map(([key, value]) => {
// Translate each key/value pair into a single predicate
return isMatchingPropOf(key, value);
});
// Return a predicate function that takes the array of single key/value pair predicates and asserts that *every* pred in the array is true of the (TextTrack-like) object
return (textTrack) => preds.every((pred) => pred(textTrack));
}
export function isMatchingPropOf(key: string | number, value: any): (candidate: TextTrack) => boolean {
return function matchProp(candidate): boolean {
return (candidate as unknown as Record<string | number, unknown>)[key] === value;
};
}
function isSubtitleTrack(textTrack: TextTrack): boolean {
return ['subtitles', 'captions'].includes(textTrack.kind);
return (Array.from(media.textTracks) as TextTrack[]).filter(filterPred).sort(sortByTextTrackKind);
}
function sortByTextTrackKind(a: TextTrack, b: TextTrack): number {
return a.kind >= b.kind ? 1 : -1;
}
function alwaysTrue(): boolean {
return true;
}