From 760870fdf28394021166df7f4ad575730dc65dbd Mon Sep 17 00:00:00 2001 From: Sam Potts Date: Tue, 26 May 2026 09:49:50 +1000 Subject: [PATCH] fix(core): disable toggle captions when there are no captions (#1598) --- .../ui/captions-button/captions-button-core.ts | 5 ++--- .../core/src/core/ui/input-feedback/status.ts | 3 +++ .../core/ui/input-feedback/tests/status.test.ts | 1 + .../dom/media/google-cast/google-cast-provider.ts | 8 ++++---- packages/core/src/dom/media/hls/text-tracks.ts | 6 +++--- .../core/src/dom/store/features/text-track.ts | 9 +++------ packages/core/src/dom/ui/input-action.ts | 7 ++++++- .../core/src/dom/ui/tests/input-action.test.ts | 2 ++ .../spf/src/media/dom/text/text-track-slots.ts | 6 ++++-- packages/utils/src/dom/index.ts | 7 ++++++- packages/utils/src/dom/tests/text-track.test.ts | 15 ++++++++++++++- packages/utils/src/dom/text-track.ts | 7 +++++++ 12 files changed, 55 insertions(+), 21 deletions(-) diff --git a/packages/core/src/core/ui/captions-button/captions-button-core.ts b/packages/core/src/core/ui/captions-button/captions-button-core.ts index e09fae75..21e33ee1 100644 --- a/packages/core/src/core/ui/captions-button/captions-button-core.ts +++ b/packages/core/src/core/ui/captions-button/captions-button-core.ts @@ -1,4 +1,5 @@ import { createState } from '@videojs/store'; +import { isCaptionOrSubtitleTrack } from '@videojs/utils/dom'; import { defaults } from '@videojs/utils/object'; import { isFunction } from '@videojs/utils/predicate'; import type { NonNullableObject } from '@videojs/utils/types'; @@ -66,9 +67,7 @@ export class CaptionsButtonCore { getState(): CaptionsButtonState { const media = this.#media!; - const availability: CaptionsButtonState['availability'] = media.textTrackList.some( - (t) => t.kind === 'captions' || t.kind === 'subtitles' - ) + const availability: CaptionsButtonState['availability'] = media.textTrackList.some(isCaptionOrSubtitleTrack) ? 'available' : 'unavailable'; diff --git a/packages/core/src/core/ui/input-feedback/status.ts b/packages/core/src/core/ui/input-feedback/status.ts index 046b9065..aa6d346d 100644 --- a/packages/core/src/core/ui/input-feedback/status.ts +++ b/packages/core/src/core/ui/input-feedback/status.ts @@ -46,6 +46,8 @@ export interface MediaSnapshot { muted?: boolean | undefined; fullscreen?: boolean | undefined; subtitlesShowing?: boolean | undefined; + /** When false, caption toggles are unavailable and status feedback is suppressed. */ + subtitlesAvailable?: boolean | undefined; pip?: boolean | undefined; currentTime?: number | undefined; duration?: number | undefined; @@ -111,6 +113,7 @@ export function deriveStatus( case 'volumeStep': return deriveVolumeStatus(event, snapshot, labels); case 'toggleSubtitles': { + if (snapshot.subtitlesAvailable === false) return null; const showing = snapshot.subtitlesShowing !== undefined ? !snapshot.subtitlesShowing : true; return { status: showing ? 'captions-on' : 'captions-off', diff --git a/packages/core/src/core/ui/input-feedback/tests/status.test.ts b/packages/core/src/core/ui/input-feedback/tests/status.test.ts index 1d72f6b2..a209e97a 100644 --- a/packages/core/src/core/ui/input-feedback/tests/status.test.ts +++ b/packages/core/src/core/ui/input-feedback/tests/status.test.ts @@ -49,6 +49,7 @@ describe('status', () => { it('derives captions, fullscreen, and picture-in-picture statuses', () => { expect(deriveStatus({ action: 'toggleSubtitles' }, SNAPSHOT)?.status).toBe('captions-on'); + expect(deriveStatus({ action: 'toggleSubtitles' }, { ...SNAPSHOT, subtitlesAvailable: false })).toBeNull(); expect(deriveStatus({ action: 'toggleFullscreen' }, SNAPSHOT)?.status).toBe('fullscreen'); expect(deriveStatus({ action: 'toggleFullscreen' }, { ...SNAPSHOT, fullscreen: true })?.status).toBe( 'exit-fullscreen' diff --git a/packages/core/src/dom/media/google-cast/google-cast-provider.ts b/packages/core/src/dom/media/google-cast/google-cast-provider.ts index c0089776..ba0e69bd 100644 --- a/packages/core/src/dom/media/google-cast/google-cast-provider.ts +++ b/packages/core/src/dom/media/google-cast/google-cast-provider.ts @@ -1,3 +1,5 @@ +import { isCaptionOrSubtitleTrack } from '@videojs/utils/dom'; + import type { RemotePlaybackState } from './remote-playback'; import type { GoogleCastMediaElement } from './types'; import { @@ -141,9 +143,7 @@ export class GoogleCastProvider { const mediaInfo = new chrome.cast.media.MediaInfo(this.media.castSrc, this.media.castContentType ?? ''); mediaInfo.customData = (this.media.castCustomData as object) ?? null; - const subtitles = [...this.media.querySelectorAll('track')].filter( - (el) => el.src && (el.kind === 'subtitles' || el.kind === 'captions') - ); + const subtitles = [...this.media.querySelectorAll('track')].filter((el) => el.src && isCaptionOrSubtitleTrack(el)); const { Track, TrackType, TextTrackType } = chrome.cast.media; const activeTrackIds: number[] = []; @@ -481,7 +481,7 @@ export class GoogleCastProvider { async #updateRemoteTextTrack() { if (!this.isCasting) return; - const localSubs = [...this.media.textTracks].filter(({ kind }) => kind === 'subtitles' || kind === 'captions'); + const localSubs = [...this.media.textTracks].filter(isCaptionOrSubtitleTrack); const matched = (this.#remote.mediaInfo?.tracks ?? []) .filter(({ type }) => type === chrome.cast.media.TrackType.TEXT) diff --git a/packages/core/src/dom/media/hls/text-tracks.ts b/packages/core/src/dom/media/hls/text-tracks.ts index bfa088d4..0fbe5548 100644 --- a/packages/core/src/dom/media/hls/text-tracks.ts +++ b/packages/core/src/dom/media/hls/text-tracks.ts @@ -1,4 +1,4 @@ -import { listen } from '@videojs/utils/dom'; +import { isCaptionOrSubtitleTrack, listen } from '@videojs/utils/dom'; import type { Constructor } from '@videojs/utils/types'; import type { CuesParsedData, NonNativeTextTracksData } from 'hls.js'; import Hls from 'hls.js'; @@ -83,7 +83,7 @@ export function HlsJsMediaTextTracksMixin { - return textTrack.id && textTrack.mode === 'showing' && ['subtitles', 'captions'].includes(textTrack.kind); + return textTrack.id && textTrack.mode === 'showing' && isCaptionOrSubtitleTrack(textTrack); }); if (!showingTrack) return; @@ -165,7 +165,7 @@ function addTextTrack( if (defaultTrack) { trackEl.default = true; } - trackEl.track.mode = ['subtitles', 'captions'].includes(kind) ? 'disabled' : 'hidden'; + trackEl.track.mode = isCaptionOrSubtitleTrack({ kind }) ? 'disabled' : 'hidden'; // Add data attribute to identify tracks that should be removed when switching sources/destroying hls.js instance. trackEl.setAttribute('data-removeondestroy', ''); diff --git a/packages/core/src/dom/store/features/text-track.ts b/packages/core/src/dom/store/features/text-track.ts index de713d2f..cfddf681 100644 --- a/packages/core/src/dom/store/features/text-track.ts +++ b/packages/core/src/dom/store/features/text-track.ts @@ -1,4 +1,4 @@ -import { findTrackElement, getTextTrackList, listen } from '@videojs/utils/dom'; +import { findTrackElement, getTextTrackList, isCaptionOrSubtitleTrack, listen } from '@videojs/utils/dom'; import type { MediaTextCue, MediaTextTrack, MediaTextTrackState } from '../../../core/media/state'; import type { TextTrackLike } from '../../../core/media/types'; @@ -17,10 +17,7 @@ export const textTrackFeature = definePlayerFeature({ const { media } = target(); if (!isMediaTextTrackCapable(media)) return false; - const subtitlesTracks = getTextTrackList( - media, - (track) => track.kind === 'subtitles' || track.kind === 'captions' - ); + const subtitlesTracks = getTextTrackList(media, isCaptionOrSubtitleTrack); if (!subtitlesTracks.length) return false; const showing = subtitlesTracks.some((track) => track.mode === 'showing'); @@ -62,7 +59,7 @@ export const textTrackFeature = definePlayerFeature({ mode: track.mode, }); - if ((track.kind === 'captions' || track.kind === 'subtitles') && track.mode === 'showing') { + if (isCaptionOrSubtitleTrack(track) && track.mode === 'showing') { subtitlesShowing = true; } } diff --git a/packages/core/src/dom/ui/input-action.ts b/packages/core/src/dom/ui/input-action.ts index 519ad4a9..743d183c 100644 --- a/packages/core/src/dom/ui/input-action.ts +++ b/packages/core/src/dom/ui/input-action.ts @@ -1,3 +1,5 @@ +import { isCaptionOrSubtitleTrack } from '@videojs/utils/dom'; + import { IndicatorVisibilityCoordinator } from '../../core/ui/input-feedback/indicator-lifecycle'; import type { InputActionEvent, MediaSnapshot } from '../../core/ui/input-feedback/status'; import { getGestureCoordinator } from '../gesture/coordinator'; @@ -34,12 +36,15 @@ export function getMediaSnapshot(store: MediaSnapshotStore | undefined): MediaSn const state = store.state; const time = selectTime(state); + const textTrack = selectTextTrack(state); + return { paused: selectPlayback(state)?.paused, volume: selectVolume(state)?.volume, muted: selectVolume(state)?.muted, fullscreen: selectFullscreen(state)?.fullscreen, - subtitlesShowing: selectTextTrack(state)?.subtitlesShowing, + subtitlesShowing: textTrack?.subtitlesShowing, + subtitlesAvailable: textTrack ? (textTrack.textTrackList ?? []).some(isCaptionOrSubtitleTrack) : undefined, pip: selectPiP(state)?.pip, currentTime: time?.currentTime, duration: time?.duration, diff --git a/packages/core/src/dom/ui/tests/input-action.test.ts b/packages/core/src/dom/ui/tests/input-action.test.ts index 93fcad49..afd91228 100644 --- a/packages/core/src/dom/ui/tests/input-action.test.ts +++ b/packages/core/src/dom/ui/tests/input-action.test.ts @@ -38,6 +38,7 @@ describe('input-action', () => { muted: false, fullscreen: true, subtitlesShowing: true, + textTrackList: [{ kind: 'captions', label: 'English', language: 'en', mode: 'showing' }], pip: false, currentTime: 30, duration: 120, @@ -49,6 +50,7 @@ describe('input-action', () => { muted: false, fullscreen: true, subtitlesShowing: true, + subtitlesAvailable: true, pip: false, currentTime: 30, duration: 120, diff --git a/packages/spf/src/media/dom/text/text-track-slots.ts b/packages/spf/src/media/dom/text/text-track-slots.ts index 9303548c..b66430ad 100644 --- a/packages/spf/src/media/dom/text/text-track-slots.ts +++ b/packages/spf/src/media/dom/text/text-track-slots.ts @@ -1,3 +1,5 @@ +import { isCaptionOrSubtitleTrack } from '@videojs/utils/dom'; + import type { PartiallyResolvedTextTrack, TextTrack } from '../../types'; /** @@ -44,7 +46,7 @@ export function getShowingSubtitlesTrackFromMedia(mediaElement: HTMLMediaElement const elements = mediaElement.querySelectorAll(SPF_TRACK_SELECTOR); for (const el of elements) { const track = el.track; - if (track.mode === 'showing' && (track.kind === 'subtitles' || track.kind === 'captions')) { + if (track.mode === 'showing' && isCaptionOrSubtitleTrack(track)) { return track; } } @@ -72,7 +74,7 @@ export function removeAllSubtitlesTracksFromMedia(mediaElement: HTMLMediaElement export function syncTextTrackModes(textTracks: TextTrackList, selectedId: string | undefined): void { for (let i = 0; i < textTracks.length; i++) { const track = textTracks[i]!; - if (track.kind !== 'subtitles' && track.kind !== 'captions') continue; + if (!isCaptionOrSubtitleTrack(track)) continue; track.mode = track.id === selectedId ? 'showing' : 'disabled'; } } diff --git a/packages/utils/src/dom/index.ts b/packages/utils/src/dom/index.ts index f7589a3a..43bff596 100644 --- a/packages/utils/src/dom/index.ts +++ b/packages/utils/src/dom/index.ts @@ -26,6 +26,11 @@ export { getSlottedElement, querySlot } from './slotted'; export { applyStyles, resolveCSSLength } from './style'; export { supportsAnchorPositioning, supportsAnimationFrame, supportsIdleCallback } from './supports'; export { createTemplate, renderTemplate } from './template'; -export { findTrackElement, getTextTrackList } from './text-track'; +export { + type CaptionOrSubtitleKind, + findTrackElement, + getTextTrackList, + isCaptionOrSubtitleTrack, +} from './text-track'; export { serializeTimeRanges } from './time-ranges'; export type { CustomElement, CustomElementCallbacks } from './types'; diff --git a/packages/utils/src/dom/tests/text-track.test.ts b/packages/utils/src/dom/tests/text-track.test.ts index 8c8ff2f5..79c9101e 100644 --- a/packages/utils/src/dom/tests/text-track.test.ts +++ b/packages/utils/src/dom/tests/text-track.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { findTrackElement } from '../text-track'; +import { findTrackElement, isCaptionOrSubtitleTrack } from '../text-track'; /** * jsdom does not create unique TextTrack objects per element, @@ -12,6 +12,19 @@ function mockTrackProperty(el: HTMLTrackElement): TextTrack { return track; } +describe('isCaptionOrSubtitleTrack', () => { + it('returns true for captions and subtitles tracks', () => { + expect(isCaptionOrSubtitleTrack({ kind: 'captions' })).toBe(true); + expect(isCaptionOrSubtitleTrack({ kind: 'subtitles' })).toBe(true); + }); + + it('returns false for other text track kinds', () => { + expect(isCaptionOrSubtitleTrack({ kind: 'chapters' })).toBe(false); + expect(isCaptionOrSubtitleTrack({ kind: 'metadata' })).toBe(false); + expect(isCaptionOrSubtitleTrack({ kind: 'descriptions' })).toBe(false); + }); +}); + describe('findTrackElement', () => { it('returns the track element that owns the given TextTrack', () => { const video = document.createElement('video'); diff --git a/packages/utils/src/dom/text-track.ts b/packages/utils/src/dom/text-track.ts index 85b191da..8e0f22e6 100644 --- a/packages/utils/src/dom/text-track.ts +++ b/packages/utils/src/dom/text-track.ts @@ -1,3 +1,10 @@ +export type CaptionOrSubtitleKind = 'captions' | 'subtitles'; + +/** Whether a text track is a captions or subtitles track. */ +export function isCaptionOrSubtitleTrack(track: { kind: string }): track is { kind: CaptionOrSubtitleKind } { + return track.kind === 'captions' || track.kind === 'subtitles'; +} + /** Find the `` element that owns the given `TextTrack`. */ export function findTrackElement(media: EventTarget, track: unknown): HTMLTrackElement | null { if (!(media instanceof HTMLElement)) return null;