diff --git a/packages/core/package.json b/packages/core/package.json index 1f364b37..ca21695e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -27,9 +27,9 @@ "default": "./dist/default/dom.js" }, "./dom/media/*": { - "types": "./dist/dev/dom/media/*.d.ts", - "development": "./dist/dev/dom/media/*.js", - "default": "./dist/default/dom/media/*.js" + "types": "./dist/dev/dom/media/*/index.d.ts", + "development": "./dist/dev/dom/media/*/index.js", + "default": "./dist/default/dom/media/*/index.js" } }, "main": "dist/default/index.js", diff --git a/packages/core/src/core/index.ts b/packages/core/src/core/index.ts index f4e45dc0..d83f7c66 100644 --- a/packages/core/src/core/index.ts +++ b/packages/core/src/core/index.ts @@ -4,6 +4,8 @@ export * from './ui/alert-dialog/alert-dialog-core'; export * from './ui/alert-dialog/alert-dialog-data-attrs'; export * from './ui/buffering-indicator/buffering-indicator-core'; export * from './ui/buffering-indicator/buffering-indicator-data-attrs'; +export * from './ui/captions-button/captions-button-core'; +export * from './ui/captions-button/captions-button-data-attrs'; export * from './ui/controls/controls-core'; export * from './ui/controls/controls-data-attrs'; export * from './ui/fullscreen-button/fullscreen-button-core'; diff --git a/packages/core/src/core/media/state.ts b/packages/core/src/core/media/state.ts index af2286b7..85a8b6a3 100644 --- a/packages/core/src/core/media/state.ts +++ b/packages/core/src/core/media/state.ts @@ -191,12 +191,43 @@ export interface MediaPlaybackRateState { setPlaybackRate(rate: number): void; } +/** + * A text cue. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/VTTCue + */ export interface MediaTextCue { startTime: number; endTime: number; text: string; } +/** + * The kind of text track. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/TextTrack/kind + */ +export type TextTrackKind = 'subtitles' | 'captions' | 'descriptions' | 'chapters' | 'metadata'; + +/** + * The mode of a text track. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/TextTrack/mode + */ +export type TextTrackMode = 'showing' | 'disabled' | 'hidden'; + +/** + * A text track. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/TextTrack + */ +export interface MediaTextTrack { + kind: Kind; + label: string; + language: string; + mode: TextTrackMode; +} + export interface MediaTextTrackState { /** Cues from the first `kind="chapters"` track. */ chaptersCues: MediaTextCue[]; @@ -204,6 +235,12 @@ export interface MediaTextTrackState { thumbnailCues: MediaTextCue[]; /** The `` 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'>[]; + /** Whether captions/subtitles are currently enabled. */ + subtitlesShowing: boolean; + /** Toggle captions/subtitles visibility. Returns the new enabled value. */ + toggleSubtitles(forceShow?: boolean): boolean; } export interface MediaError { 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 new file mode 100644 index 00000000..d957ef2b --- /dev/null +++ b/packages/core/src/core/ui/captions-button/captions-button-core.ts @@ -0,0 +1,76 @@ +import { defaults } from '@videojs/utils/object'; +import { isFunction } from '@videojs/utils/predicate'; +import type { NonNullableObject } from '@videojs/utils/types'; + +import type { MediaTextTrackState } from '../../media/state'; + +export interface CaptionsButtonProps { + /** Custom label for the button. */ + label?: string | ((state: CaptionsButtonState) => string) | undefined; + /** Whether the button is disabled. */ + disabled?: boolean | undefined; +} + +export interface CaptionsButtonState extends Pick { + availability: 'available' | 'unavailable'; +} + +export class CaptionsButtonCore { + static readonly defaultProps: NonNullableObject = { + label: '', + disabled: false, + }; + + #props = { ...CaptionsButtonCore.defaultProps }; + #media: MediaTextTrackState | null = null; + + constructor(props?: CaptionsButtonProps) { + if (props) this.setProps(props); + } + + setProps(props: CaptionsButtonProps): void { + this.#props = defaults(props, CaptionsButtonCore.defaultProps); + } + + getLabel(state: CaptionsButtonState): string { + const { label } = this.#props; + + if (isFunction(label)) { + const customLabel = label(state); + if (customLabel) return customLabel; + } else if (label) { + return label; + } + + return state.subtitlesShowing ? 'Disable captions' : 'Enable captions'; + } + + getAttrs(state: CaptionsButtonState) { + return { + 'aria-label': this.getLabel(state), + 'aria-disabled': this.#props.disabled ? 'true' : undefined, + }; + } + + setMedia(media: MediaTextTrackState): void { + this.#media = media; + } + + getState(): CaptionsButtonState { + const media = this.#media!; + return { + subtitlesShowing: media.subtitlesShowing, + availability: media.subtitlesList.length > 0 ? 'available' : 'unavailable', + }; + } + + toggle(media: MediaTextTrackState): void { + if (this.#props.disabled) return; + media.toggleSubtitles(); + } +} + +export namespace CaptionsButtonCore { + export type Props = CaptionsButtonProps; + export type State = CaptionsButtonState; +} diff --git a/packages/core/src/core/ui/captions-button/captions-button-data-attrs.ts b/packages/core/src/core/ui/captions-button/captions-button-data-attrs.ts new file mode 100644 index 00000000..27ce9b48 --- /dev/null +++ b/packages/core/src/core/ui/captions-button/captions-button-data-attrs.ts @@ -0,0 +1,9 @@ +import type { StateAttrMap } from '../types'; +import type { CaptionsButtonState } from './captions-button-core'; + +export const CaptionsButtonDataAttrs = { + /** Present when captions are enabled. */ + subtitlesShowing: 'data-active', + /** Indicates captions availability (`available` or `unavailable`). */ + availability: 'data-availability', +} as const satisfies StateAttrMap; diff --git a/packages/core/src/core/ui/captions-button/tests/captions-button-core.test.ts b/packages/core/src/core/ui/captions-button/tests/captions-button-core.test.ts new file mode 100644 index 00000000..54c6996f --- /dev/null +++ b/packages/core/src/core/ui/captions-button/tests/captions-button-core.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { MediaTextTrackState } from '../../../media/state'; +import type { CaptionsButtonState } from '../captions-button-core'; +import { CaptionsButtonCore } from '../captions-button-core'; + +function createMediaState(overrides: Partial = {}): MediaTextTrackState { + return { + chaptersCues: [], + thumbnailCues: [], + thumbnailTrackSrc: null, + subtitlesList: [], + subtitlesShowing: false, + toggleSubtitles: vi.fn(() => true), + ...overrides, + }; +} + +function createState(overrides: Partial = {}): CaptionsButtonState { + return { + subtitlesShowing: false, + availability: 'available', + ...overrides, + }; +} + +describe('CaptionsButtonCore', () => { + describe('getState', () => { + it('projects captions', () => { + const core = new CaptionsButtonCore(); + const media = createMediaState({ + subtitlesShowing: true, + subtitlesList: [{ kind: 'subtitles', label: 'English', language: 'en', mode: 'showing' }], + }); + core.setMedia(media); + const state = core.getState(); + + expect(state.subtitlesShowing).toBe(true); + }); + + it('returns available when subtitles exist', () => { + const core = new CaptionsButtonCore(); + core.setMedia( + createMediaState({ + subtitlesList: [{ kind: 'subtitles', label: 'English', language: 'en', mode: 'disabled' }], + }) + ); + + expect(core.getState().availability).toBe('available'); + }); + + it('returns unavailable when no subtitles', () => { + const core = new CaptionsButtonCore(); + core.setMedia(createMediaState({ subtitlesList: [] })); + + expect(core.getState().availability).toBe('unavailable'); + }); + }); + + describe('getLabel', () => { + it('returns Enable captions when captions are disabled', () => { + const core = new CaptionsButtonCore(); + expect(core.getLabel(createState({ subtitlesShowing: false }))).toBe('Enable captions'); + }); + + it('returns Disable captions when captions are enabled', () => { + const core = new CaptionsButtonCore(); + expect(core.getLabel(createState({ subtitlesShowing: true }))).toBe('Disable captions'); + }); + + it('returns custom string label', () => { + const core = new CaptionsButtonCore({ label: 'Captions' }); + expect(core.getLabel(createState())).toBe('Captions'); + }); + + it('returns custom function label', () => { + const core = new CaptionsButtonCore({ + label: (state) => (state.subtitlesShowing ? 'Hide subtitles' : 'Show subtitles'), + }); + expect(core.getLabel(createState({ subtitlesShowing: true }))).toBe('Hide subtitles'); + }); + }); + + describe('getAttrs', () => { + it('returns aria-label', () => { + const core = new CaptionsButtonCore(); + const attrs = core.getAttrs(createState({ subtitlesShowing: false })); + expect(attrs['aria-label']).toBe('Enable captions'); + }); + + it('sets aria-disabled when disabled', () => { + const core = new CaptionsButtonCore({ disabled: true }); + const attrs = core.getAttrs(createState()); + expect(attrs['aria-disabled']).toBe('true'); + }); + }); + + describe('toggle', () => { + it('calls toggleSubtitles when available', () => { + const core = new CaptionsButtonCore(); + const media = createMediaState(); + core.toggle(media); + expect(media.toggleSubtitles).toHaveBeenCalled(); + }); + + it('does nothing when disabled', () => { + const core = new CaptionsButtonCore({ disabled: true }); + const media = createMediaState(); + core.toggle(media); + expect(media.toggleSubtitles).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/core/src/dom/media/custom-media-element.ts b/packages/core/src/dom/media/custom-media-element/index.ts similarity index 93% rename from packages/core/src/dom/media/custom-media-element.ts rename to packages/core/src/dom/media/custom-media-element/index.ts index 1b9f7bfb..99194d69 100644 --- a/packages/core/src/dom/media/custom-media-element.ts +++ b/packages/core/src/dom/media/custom-media-element/index.ts @@ -129,7 +129,7 @@ declare class CustomAudioElementClass extends HTMLAudioElement implements HTMLAu static getTemplateHTML: typeof getAudioTemplateHTML; static shadowRootOptions: ShadowRootInit; static Events: string[]; - readonly nativeEl: HTMLAudioElement; + readonly target: HTMLAudioElement; attributeChangedCallback(attrName: string, oldValue?: string | null, newValue?: string | null): void; connectedCallback(): void; disconnectedCallback(): void; @@ -142,7 +142,7 @@ declare class CustomVideoElementClass extends HTMLVideoElement implements HTMLVi static getTemplateHTML: typeof getVideoTemplateHTML; static shadowRootOptions: ShadowRootInit; static Events: string[]; - readonly nativeEl: HTMLVideoElement; + readonly target: HTMLVideoElement; attributeChangedCallback(attrName: string, oldValue?: string | null, newValue?: string | null): void; connectedCallback(): void; disconnectedCallback(): void; @@ -239,7 +239,7 @@ export function CustomMediaMixin>( // Private fields #isInit = false; - #nativeEl: HTMLVideoElement | HTMLAudioElement | null = null; + #target: HTMLVideoElement | HTMLAudioElement | null = null; #childMap = new Map(); #childObserver?: MutationObserver; @@ -249,7 +249,7 @@ export function CustomMediaMixin>( const val = this.getAttribute(attr); return val === null ? false : val === '' ? true : val; } - return this.nativeEl?.[prop as keyof typeof this.nativeEl]; + return this.target?.[prop as keyof typeof this.target]; } set(prop: string, val: any): void { @@ -263,15 +263,15 @@ export function CustomMediaMixin>( return; } - if (this.nativeEl) { + if (this.target) { // @ts-expect-error - this.nativeEl[prop as keyof typeof this.nativeEl] = val; + this.target[prop as keyof typeof this.target] = val; } } call(prop: string, ...args: any[]): any { - const nativeFn = this.nativeEl?.[prop as keyof typeof this.nativeEl] as ((...args: any[]) => any) | undefined; - return nativeFn?.apply(this.nativeEl, args); + const nativeFn = this.target?.[prop as keyof typeof this.target] as ((...args: any[]) => any) | undefined; + return nativeFn?.apply(this.target, args); } // If the custom element is defined before the custom element's HTML is parsed @@ -279,10 +279,10 @@ export function CustomMediaMixin>( // Wait until initializing in the attributeChangedCallback or // connectedCallback or accessing any properties. - get nativeEl() { + get target() { this.#init(); return ( - this.#nativeEl ?? + this.#target ?? this.querySelector(':scope > [slot=media]') ?? this.querySelector(tag) ?? this.shadowRoot?.querySelector(tag) ?? @@ -290,8 +290,8 @@ export function CustomMediaMixin>( ); } - set nativeEl(val: HTMLVideoElement | HTMLAudioElement | null) { - this.#nativeEl = val; + set target(val: HTMLVideoElement | HTMLAudioElement | null) { + this.#target = val; } get defaultMuted() { @@ -323,7 +323,7 @@ export function CustomMediaMixin>( // Neither Chrome or Firefox support setting the muted attribute // after using document.createElement. // Get around this by setting the muted property manually. - this.nativeEl!.muted = this.hasAttribute('muted'); + this.target!.muted = this.hasAttribute('muted'); for (const prop of nativeElProps) { // @ts-expect-error @@ -340,7 +340,7 @@ export function CustomMediaMixin>( } handleEvent(event: Event): void { - if (event.target === this.nativeEl) { + if (event.target === this.target) { this.dispatchEvent(new CustomEvent(event.type, { detail: (event as CustomEvent).detail })); } } @@ -361,7 +361,7 @@ export function CustomMediaMixin>( this.#childMap.set(el, clone); this.#childObserver?.observe(el, { attributes: true }); } - this.nativeEl?.append(clone); + this.target?.append(clone); this.#enableDefaultTrack(clone as HTMLTrackElement); }); @@ -426,9 +426,9 @@ export function CustomMediaMixin>( } if (newValue === null) { - this.nativeEl?.removeAttribute(attrName); - } else if (this.nativeEl?.getAttribute(attrName) !== newValue) { - this.nativeEl?.setAttribute(attrName, newValue); + this.target?.removeAttribute(attrName); + } else if (this.target?.getAttribute(attrName) !== newValue) { + this.target?.setAttribute(attrName, newValue); } } diff --git a/packages/core/src/dom/media/hls.ts b/packages/core/src/dom/media/hls/index.ts similarity index 55% rename from packages/core/src/dom/media/hls.ts rename to packages/core/src/dom/media/hls/index.ts index 21999590..1e6189c0 100644 --- a/packages/core/src/dom/media/hls.ts +++ b/packages/core/src/dom/media/hls/index.ts @@ -1,11 +1,24 @@ import Hls from 'hls.js'; -import { type MediaDelegate, MediaDelegateMixin } from '../../core/media/delegate'; -import { MediaProxyMixin } from '../../core/media/proxy'; -import { CustomMediaMixin } from './custom-media-element'; +import { type MediaDelegate, MediaDelegateMixin } from '../../../core/media/delegate'; +import { MediaProxyMixin } from '../../../core/media/proxy'; +import { CustomMediaMixin } from '../custom-media-element'; +import { HlsMediaTextTracksMixin } from './text-tracks'; -export class HlsMediaDelegate implements MediaDelegate { - #engine = new Hls(); +const defaultConfig = { + backBufferLength: 30, + renderTextTracksNatively: false, + liveDurationInfinity: true, + capLevelToPlayerSize: true, + capLevelOnFPSDrop: true, +}; + +export class HlsMediaDelegateBase implements MediaDelegate { + #engine = new Hls(defaultConfig); + + get engine(): Hls { + return this.#engine; + } attach(target: EventTarget): void { this.#engine.attachMedia(target as HTMLMediaElement); @@ -24,6 +37,8 @@ export class HlsMediaDelegate implements MediaDelegate { } } +const HlsMediaDelegate = HlsMediaTextTracksMixin(HlsMediaDelegateBase); + // This is used by the web component because it needs to extend HTMLElement! export class HlsCustomMedia extends MediaDelegateMixin( CustomMediaMixin(globalThis.HTMLElement ?? class {}, { tag: 'video' }), diff --git a/packages/core/src/dom/media/hls/text-tracks.ts b/packages/core/src/dom/media/hls/text-tracks.ts new file mode 100644 index 00000000..a7caab85 --- /dev/null +++ b/packages/core/src/dom/media/hls/text-tracks.ts @@ -0,0 +1,178 @@ +import { listen } from '@videojs/utils/dom'; +import type { Constructor } from '@videojs/utils/types'; +import type { CuesParsedData, NonNativeTextTracksData } from 'hls.js'; +import Hls from 'hls.js'; + +interface HlsEngineHost { + readonly engine: Hls; + attach?(target: EventTarget): void; + detach?(): void; +} + +/** + * Bridges hls.js non-native text tracks to native `` elements so the + * rest of the player can treat them like any other text track. + * + * When `renderTextTracksNatively: false`, hls.js fires + * `NON_NATIVE_TEXT_TRACKS_FOUND` with track metadata and `CUES_PARSED` with + * VTTCues. This mixin creates `` elements on the media target and + * forwards cues into them. It also syncs user track-mode changes back to + * hls.js via `engine.subtitleTrack`. + */ +export function HlsMediaTextTracksMixin>(BaseClass: Base) { + class HlsMediaTextTracks extends (BaseClass as Constructor) { + #disconnect: AbortController | null = null; + #target: HTMLMediaElement | null = null; + + attach(target: EventTarget): void { + super.attach?.(target); + this.#target = target as HTMLMediaElement; + this.#connect(); + } + + detach(): void { + this.#disconnect?.abort(); + this.#disconnect = null; + this.#target = null; + super.detach?.(); + } + + #connect(): void { + this.#disconnect?.abort(); + this.#disconnect = new AbortController(); + + const { signal } = this.#disconnect; + const { engine } = this; + const media = this.#target!; + + const onTracksFound = (_event: string, data: NonNativeTextTracksData) => { + this.#clearTracks(); + + for (const trackObj of data.tracks) { + const baseTrackObj = trackObj.subtitleTrack ?? trackObj.closedCaptions; + const idx = engine.subtitleTracks.findIndex(({ lang, name, type }) => { + return lang === baseTrackObj?.lang && name === trackObj.label && type.toLowerCase() === trackObj.kind; + }); + + // NOTE: Undocumented method for determining identifier by hls.js. Relied on for + // ensuring CUES_PARSED events can identify and apply cues to the appropriate track (CJP). + // See: https://github.com/video-dev/hls.js/blob/master/src/controller/timeline-controller.ts#L640 + const id = (trackObj._id ?? trackObj.default) ? 'default' : `${trackObj.kind}${idx}`; + + addTextTrack(media, trackObj.kind as TextTrackKind, trackObj.label, baseTrackObj?.lang, id, trackObj.default); + } + }; + + const onCuesParsed = (_event: string, { track, cues }: CuesParsedData) => { + const textTrack = media.textTracks.getTrackById(track); + if (!textTrack) return; + + const disabled = textTrack.mode === 'disabled'; + if (disabled) { + textTrack.mode = 'hidden'; + } + + cues.forEach((cue: VTTCue) => { + if (textTrack.cues?.getCueById(cue.id)) return; + textTrack.addCue(cue); + }); + + if (disabled) { + textTrack.mode = 'disabled'; + } + }; + + const onTextTrackChange = () => { + if (!engine.subtitleTracks.length) return; + + const showingTrack = Array.from(media.textTracks).find((textTrack) => { + return textTrack.id && textTrack.mode === 'showing' && ['subtitles', 'captions'].includes(textTrack.kind); + }); + + if (!showingTrack) return; + + const currentHlsTrack = engine.subtitleTracks[engine.subtitleTrack]; + + // If hls.subtitleTrack is -1 or its id changed compared to the one that is showing load the new subtitle track. + const hlsTrackId = !currentHlsTrack + ? undefined + : currentHlsTrack.default + ? 'default' + : `${engine.subtitleTracks[engine.subtitleTrack]?.type.toLowerCase()}${engine.subtitleTrack}`; + + if (engine.subtitleTrack < 0 || showingTrack?.id !== hlsTrackId) { + const idx = engine.subtitleTracks.findIndex(({ lang, name, type, default: defaultTrack }) => { + return ( + (showingTrack.id === 'default' && defaultTrack) || + (lang === showingTrack.language && + name === showingTrack.label && + type.toLowerCase() === showingTrack.kind) + ); + }); + // After the subtitleTrack is set here, hls.js will load the playlist and CUES_PARSED events will be fired below. + engine.subtitleTrack = idx; + } + + if (showingTrack?.id === hlsTrackId) { + // Refresh the cues after a texttrack mode change to fix a Chrome bug causing the captions not to render. + if (showingTrack.cues) { + Array.from(showingTrack.cues).forEach((cue) => { + showingTrack.addCue(cue); + }); + } + } + }; + + engine.on(Hls.Events.NON_NATIVE_TEXT_TRACKS_FOUND, onTracksFound); + engine.on(Hls.Events.CUES_PARSED, onCuesParsed); + listen(media.textTracks, 'change', onTextTrackChange, { signal }); + + signal.addEventListener( + 'abort', + () => { + engine.off(Hls.Events.NON_NATIVE_TEXT_TRACKS_FOUND, onTracksFound); + engine.off(Hls.Events.CUES_PARSED, onCuesParsed); + this.#clearTracks(); + }, + { once: true } + ); + } + + #clearTracks(): void { + const trackEls = this.#target!.querySelectorAll('track[data-removeondestroy]'); + trackEls.forEach((trackEl) => trackEl.remove()); + } + } + + return HlsMediaTextTracks as unknown as Base; +} + +function addTextTrack( + mediaEl: HTMLMediaElement, + kind: TextTrackKind, + label: string, + lang?: string, + id?: string, + defaultTrack?: boolean +): TextTrack { + const trackEl = document.createElement('track'); + trackEl.kind = kind; + trackEl.label = label; + if (lang) { + // This attribute must be present if the element's kind attribute is in the subtitles state. + trackEl.srclang = lang; + } + if (id) { + trackEl.id = id; + } + if (defaultTrack) { + trackEl.default = true; + } + trackEl.track.mode = ['subtitles', 'captions'].includes(kind) ? 'disabled' : 'hidden'; + + // Add data attribute to identify tracks that should be removed when switching sources/destroying hls.js instance. + trackEl.setAttribute('data-removeondestroy', ''); + mediaEl.append(trackEl); + + return trackEl.track as TextTrack; +} diff --git a/packages/core/src/dom/store/features/tests/text-track.test.ts b/packages/core/src/dom/store/features/tests/text-track.test.ts index b7f02768..326960fc 100644 --- a/packages/core/src/dom/store/features/tests/text-track.test.ts +++ b/packages/core/src/dom/store/features/tests/text-track.test.ts @@ -16,6 +16,23 @@ function createVideo(): HTMLVideoElement { return document.createElement('video'); } +function mockTextTracks(video: HTMLVideoElement, tracks: TextTrack[]): void { + const list: Partial & Record = { length: tracks.length }; + + for (const [index, track] of tracks.entries()) { + list[index] = track; + } + + Object.defineProperty(video, 'textTracks', { + configurable: true, + value: list as TextTrackList, + }); +} + +function createMockTrack(kind: TextTrackKind, mode: TextTrackMode = 'disabled'): TextTrack { + return { kind, mode, label: '', language: '' } as TextTrack; +} + describe('textTrackFeature', () => { describe('initial state', () => { it('has empty initial state', () => { @@ -26,6 +43,8 @@ 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.subtitlesShowing).toBe(false); }); }); @@ -120,6 +139,62 @@ describe('textTrackFeature', () => { } }); + it('sets subtitlesShowing when a subtitles track is showing', () => { + const video = createVideo(); + mockTextTracks(video, [createMockTrack('subtitles', 'showing')]); + + const store = createStore()(textTrackFeature); + store.attach({ media: video, container: null }); + + expect(store.state.subtitlesShowing).toBe(true); + }); + + it('exposes subtitlesList from captions/subtitles tracks', () => { + 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 store = createStore()(textTrackFeature); + store.attach({ media: video, container: null }); + + expect(store.state.subtitlesList).toEqual([ + { kind: 'subtitles', label: 'English', language: 'en', mode: 'showing' }, + { kind: 'captions', label: 'CC', language: 'en', mode: 'disabled' }, + ]); + }); + + it('toggleSubtitles() enables and disables caption/subtitle tracks', () => { + const video = createVideo(); + const subtitlesTrack = createMockTrack('subtitles'); + const captionsTrack = createMockTrack('captions'); + mockTextTracks(video, [subtitlesTrack, captionsTrack]); + + const store = createStore()(textTrackFeature); + store.attach({ media: video, container: null }); + + const enabled = store.state.toggleSubtitles(); + expect(enabled).toBe(true); + expect(subtitlesTrack.mode).toBe('showing'); + expect(captionsTrack.mode).toBe('showing'); + + const disabled = store.state.toggleSubtitles(false); + expect(disabled).toBe(false); + expect(subtitlesTrack.mode).toBe('disabled'); + expect(captionsTrack.mode).toBe('disabled'); + }); + + it('toggleSubtitles() returns false when no subtitle tracks exist', () => { + const video = createVideo(); + const metadataTrack = createMockTrack('metadata', 'showing'); + mockTextTracks(video, [metadataTrack]); + + const store = createStore()(textTrackFeature); + store.attach({ media: video, container: null }); + + expect(store.state.toggleSubtitles()).toBe(false); + }); + it('stops updating after destroy', () => { const video = createVideo(); const store = createStore()(textTrackFeature); diff --git a/packages/core/src/dom/store/features/text-track.ts b/packages/core/src/dom/store/features/text-track.ts index 2df87e6b..c6d8a732 100644 --- a/packages/core/src/dom/store/features/text-track.ts +++ b/packages/core/src/dom/store/features/text-track.ts @@ -1,14 +1,29 @@ -import { findTrackElement, listen } from '@videojs/utils/dom'; +import { findTrackElement, getSubtitlesTracks, listen } from '@videojs/utils/dom'; -import type { MediaTextCue, MediaTextTrackState } from '../../../core/media/state'; +import type { MediaTextCue, MediaTextTrack, MediaTextTrackState } from '../../../core/media/state'; import { definePlayerFeature } from '../../feature'; export const textTrackFeature = definePlayerFeature({ name: 'textTrack', - state: (): MediaTextTrackState => ({ + state: ({ target }): MediaTextTrackState => ({ chaptersCues: [], thumbnailCues: [], thumbnailTrackSrc: null, + subtitlesList: [], + subtitlesShowing: false, + toggleSubtitles(forceShow?: boolean) { + const subtitlesTracks = getSubtitlesTracks(target().media); + if (!subtitlesTracks.length) return false; + + const showing = subtitlesTracks.some((track: TextTrack) => track.mode === 'showing'); + const nextShowing = forceShow ?? !showing; + + for (const track of subtitlesTracks) { + track.mode = nextShowing ? 'showing' : 'disabled'; + } + + return nextShowing; + }, }), attach({ target, signal, set }) { @@ -22,11 +37,26 @@ export const textTrackFeature = definePlayerFeature({ let chaptersTrack: TextTrack | null = null; let thumbnailTrack: TextTrack | null = null; + const subtitlesList: MediaTextTrack<'subtitles' | 'captions'>[] = []; + 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; + } + } } // VTTCue extends TextTrackCue with `text` — cast via `unknown` since @@ -47,13 +77,13 @@ export const textTrackFeature = definePlayerFeature({ // Listen for load events on tracks that don't have cues yet. // `addtrack` fires before cues are parsed — we need the `load` event // on the element to know when cues are ready. - for (const trackEl of media.querySelectorAll('track')) { + for (const trackEl of media.querySelectorAll?.('track') ?? []) { if (!trackEl.track?.cues?.length) { listen(trackEl, 'load', sync, { signal: trackCleanup.signal }); } } - set({ chaptersCues, thumbnailCues, thumbnailTrackSrc }); + set({ chaptersCues, thumbnailCues, thumbnailTrackSrc, subtitlesList, subtitlesShowing }); } sync(); diff --git a/packages/core/tsdown.config.ts b/packages/core/tsdown.config.ts index 9fdf55bf..2b9f04d5 100644 --- a/packages/core/tsdown.config.ts +++ b/packages/core/tsdown.config.ts @@ -9,8 +9,8 @@ const createConfig = (mode: BuildMode): UserConfig => ({ entry: { index: './src/core/index.ts', dom: './src/dom/index.ts', - 'dom/media/hls': './src/dom/media/hls.ts', - 'dom/media/custom-media-element': './src/dom/media/custom-media-element.ts', + 'dom/media/hls/index': './src/dom/media/hls/index.ts', + 'dom/media/custom-media-element/index': './src/dom/media/custom-media-element/index.ts', }, platform: 'neutral', format: 'es', diff --git a/packages/html/src/define/ui/captions-button.ts b/packages/html/src/define/ui/captions-button.ts new file mode 100644 index 00000000..73ae1b38 --- /dev/null +++ b/packages/html/src/define/ui/captions-button.ts @@ -0,0 +1,9 @@ +import { CaptionsButtonElement } from '../../ui/captions-button/captions-button-element'; + +customElements.define(CaptionsButtonElement.tagName, CaptionsButtonElement); + +declare global { + interface HTMLElementTagNameMap { + [CaptionsButtonElement.tagName]: CaptionsButtonElement; + } +} diff --git a/packages/html/src/index.ts b/packages/html/src/index.ts index 5bcc58a5..471fddea 100644 --- a/packages/html/src/index.ts +++ b/packages/html/src/index.ts @@ -15,6 +15,7 @@ export * from './store/provider-mixin'; export * from './store/types'; // UI Components export { BufferingIndicatorElement } from './ui/buffering-indicator/buffering-indicator-element'; +export { CaptionsButtonElement } from './ui/captions-button/captions-button-element'; export { ControlsElement } from './ui/controls/controls-element'; export { ControlsGroupElement } from './ui/controls/controls-group-element'; export { FullscreenButtonElement } from './ui/fullscreen-button/fullscreen-button-element'; diff --git a/packages/html/src/media/hls-video/index.ts b/packages/html/src/media/hls-video/index.ts index 65aec913..4db087f1 100644 --- a/packages/html/src/media/hls-video/index.ts +++ b/packages/html/src/media/hls-video/index.ts @@ -13,7 +13,7 @@ export class HlsVideo extends HlsCustomMedia { // are appended after the custom element is created, we need to // attach the native element to the Media API after the native element // is appended to the DOM. This is currently not supported. - this.attach(this.nativeEl); + this.attach(this.target); } attributeChangedCallback(attrName: string, oldValue: string | null, newValue: string | null): void { diff --git a/packages/html/src/ui/captions-button/captions-button-element.ts b/packages/html/src/ui/captions-button/captions-button-element.ts new file mode 100644 index 00000000..b52c8eb5 --- /dev/null +++ b/packages/html/src/ui/captions-button/captions-button-element.ts @@ -0,0 +1,18 @@ +import { CaptionsButtonCore, CaptionsButtonDataAttrs, type MediaTextTrackState } from '@videojs/core'; +import { selectTextTrack } from '@videojs/core/dom'; + +import { playerContext } from '../../player/context'; +import { PlayerController } from '../../player/player-controller'; +import { MediaButtonElement } from '../media-button-element'; + +export class CaptionsButtonElement extends MediaButtonElement { + static readonly tagName = 'media-captions-button'; + + protected readonly core = new CaptionsButtonCore(); + protected readonly stateAttrMap = CaptionsButtonDataAttrs; + protected readonly mediaState = new PlayerController(this, playerContext, selectTextTrack); + + protected activate(state: MediaTextTrackState): void { + this.core.toggle(state); + } +} diff --git a/packages/react/src/ui/captions-button/captions-button.tsx b/packages/react/src/ui/captions-button/captions-button.tsx index 0409c284..bab3be13 100644 --- a/packages/react/src/ui/captions-button/captions-button.tsx +++ b/packages/react/src/ui/captions-button/captions-button.tsx @@ -1,78 +1,25 @@ 'use client'; -import type { StateAttrMap } from '@videojs/core'; -import type { ForwardedRef } from 'react'; -import { forwardRef, useState } from 'react'; +import { CaptionsButtonCore, CaptionsButtonDataAttrs } from '@videojs/core'; +import { selectTextTrack } from '@videojs/core/dom'; import type { UIComponentProps } from '../../utils/types'; -import { renderElement } from '../../utils/use-render'; -import { useButton } from '../hooks/use-button'; +import { createMediaButton } from '../create-media-button'; -// FIXME: Replace with state/props from core. -export type CaptionsButtonState = { - active: boolean; -}; -type CaptionButtonCoreProps = { - /** Custom label for the button. */ - label?: string | ((state: CaptionsButtonState) => string) | undefined; - /** Whether the button is disabled. */ - disabled?: boolean | undefined; -}; -const CaptionButtonDataAttrs = { - /** Present when the captions are active. */ - active: 'data-active', -} as const satisfies StateAttrMap; +export interface CaptionsButtonProps + extends UIComponentProps<'button', CaptionsButtonCore.State>, + CaptionsButtonCore.Props {} -export interface CaptionsButtonProps extends UIComponentProps<'button', CaptionsButtonState>, CaptionButtonCoreProps {} - -const DEBUG = false; - -/** - * A button that toggles captions. - * - * @example - * ```tsx - * - * - * ( - * - * )} - * /> - * ``` - */ -export const CaptionsButton = forwardRef(function CaptionsButton( - componentProps: CaptionsButtonProps, - forwardedRef: ForwardedRef -) { - const { render, className, style, label, disabled, ...elementProps } = componentProps; - - // FIXME: Replace with actual captions state - const [isActive, setIsActive] = useState(false); - - const { getButtonProps, buttonRef } = useButton({ - displayName: 'CaptionsButton', - onActivate: () => setIsActive((active) => !active), // FIXME: Replace with actual toggle logic - isDisabled: () => disabled ?? false, - }); - - if (!DEBUG) return null; - - return renderElement( - 'button', - { render, className, style }, - { - state: { active: isActive }, // FIXME: Replace with actual toggle logic - stateAttrMap: CaptionButtonDataAttrs, - ref: [forwardedRef, buttonRef], - props: [elementProps, getButtonProps(), { 'aria-pressed': isActive }], - } - ); +/** A button that toggles captions. */ +export const CaptionsButton = createMediaButton({ + displayName: 'CaptionsButton', + core: CaptionsButtonCore, + stateAttrMap: CaptionsButtonDataAttrs, + selector: selectTextTrack, + action: (core, state) => core.toggle(state), }); export namespace CaptionsButton { export type Props = CaptionsButtonProps; - export type State = CaptionsButtonState; + export type State = CaptionsButtonCore.State; } diff --git a/packages/react/src/ui/captions-button/index.ts b/packages/react/src/ui/captions-button/index.ts index 2328b961..63a0c393 100644 --- a/packages/react/src/ui/captions-button/index.ts +++ b/packages/react/src/ui/captions-button/index.ts @@ -1 +1 @@ -export { CaptionsButton, type CaptionsButtonProps, type CaptionsButtonState } from './captions-button'; +export { CaptionsButton, type CaptionsButtonProps } from './captions-button'; diff --git a/packages/sandbox/templates/react/main.tsx b/packages/sandbox/templates/react/main.tsx index 439ff5ed..bd395693 100644 --- a/packages/sandbox/templates/react/main.tsx +++ b/packages/sandbox/templates/react/main.tsx @@ -2,7 +2,8 @@ // http://localhost:5173/react/ import { createPlayer } from '@videojs/react'; -import { Video, videoFeatures } from '@videojs/react/video'; +import { HlsVideo } from '@videojs/react/media/hls-video'; +import { videoFeatures } from '@videojs/react/video'; import { useState } from 'react'; import { createRoot } from 'react-dom/client'; import { SKINS } from '../constants'; @@ -13,8 +14,14 @@ const { Provider } = createPlayer({ features: videoFeatures, }); +const HLS_SOURCES = [ + 'https://stream.mux.com/VcmKA6aqzIzlg3MayLJDnbF55kX00mds028Z65QxvBYaA.m3u8', + 'https://stream.mux.com/Sc89iWAyNkhJ3P1rQ02nrEdCFTnfT01CZ2KmaEcxXfB008.m3u8', +]; + function App() { const [skin, setSkin] = useState('default'); + const [src, setSrc] = useState(HLS_SOURCES[0]); return (
@@ -28,9 +35,17 @@ function App() { - + +
); } diff --git a/packages/sandbox/templates/styles.css b/packages/sandbox/templates/styles.css index 1ca525ad..c15e76fd 100644 --- a/packages/sandbox/templates/styles.css +++ b/packages/sandbox/templates/styles.css @@ -1,4 +1,5 @@ @import "tailwindcss"; +@source "../src"; @source "../templates"; /* NOTE: This is non-standard since you'd usually eject the Tailwind skin into your own project and not import it from node_modules. */ @source "../node_modules/@videojs/skins"; diff --git a/packages/utils/src/dom/index.ts b/packages/utils/src/dom/index.ts index adf39f92..e6817346 100644 --- a/packages/utils/src/dom/index.ts +++ b/packages/utils/src/dom/index.ts @@ -14,6 +14,6 @@ export { supportsIdleCallback, supportsPopoverAPI, } from './supports'; -export { findTrackElement } from './text-track'; +export { findTrackElement, getSubtitlesTracks, getTextTracksList } from './text-track'; export { serializeTimeRanges } from './time-ranges'; export type { CustomElement, CustomElementCallbacks } from './types'; diff --git a/packages/utils/src/dom/text-track.ts b/packages/utils/src/dom/text-track.ts index b813071b..9b92e88e 100644 --- a/packages/utils/src/dom/text-track.ts +++ b/packages/utils/src/dom/text-track.ts @@ -5,3 +5,46 @@ 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[] { + 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)[key] === value; + }; +} + +function isSubtitleTrack(textTrack: TextTrack): boolean { + return ['subtitles', 'captions'].includes(textTrack.kind); +} + +function sortByTextTrackKind(a: TextTrack, b: TextTrack): number { + return a.kind >= b.kind ? 1 : -1; +} + +function alwaysTrue(): boolean { + return true; +}