diff --git a/apps/sandbox/app/shared/html/storyboard.ts b/apps/sandbox/app/shared/html/storyboard.ts index 12f32b2d..e0debf8a 100644 --- a/apps/sandbox/app/shared/html/storyboard.ts +++ b/apps/sandbox/app/shared/html/storyboard.ts @@ -1,3 +1,3 @@ export function renderStoryboard(src?: string | undefined): string { - return src ? `` : ''; + return src ? `` : ''; } diff --git a/apps/sandbox/app/shared/react/storyboard.tsx b/apps/sandbox/app/shared/react/storyboard.tsx index c6ead425..2cb024ff 100644 --- a/apps/sandbox/app/shared/react/storyboard.tsx +++ b/apps/sandbox/app/shared/react/storyboard.tsx @@ -4,5 +4,5 @@ type StoryboardProps = { export function Storyboard({ src }: StoryboardProps) { if (!src) return null; - return ; + return ; } diff --git a/packages/core/src/dom/media/custom-media-element/index.ts b/packages/core/src/dom/media/custom-media-element/index.ts index 7a73b080..7c61037a 100644 --- a/packages/core/src/dom/media/custom-media-element/index.ts +++ b/packages/core/src/dom/media/custom-media-element/index.ts @@ -372,6 +372,7 @@ export function CustomMediaMixin>( this.#childObserver?.observe(el, { attributes: true }); } this.target?.append(clone); + this.#enableDefaultTrack(clone as HTMLTrackElement); }); removeNativeChildren.forEach((clone, el) => { @@ -387,11 +388,26 @@ export function CustomMediaMixin>( const clone = this.#childMap.get(target as MediaChild); if (clone && attributeName) { clone.setAttribute(attributeName, (target as MediaChild).getAttribute(attributeName) ?? ''); + this.#enableDefaultTrack(clone as HTMLTrackElement); } } } } + #enableDefaultTrack(trackEl: HTMLTrackElement): void { + // Browsers don't honor the `default` attribute if a track is added via JS. + // Enable default tracks for chapters or metadata. + if ( + trackEl && + trackEl.localName === 'track' && + trackEl.default && + (trackEl.kind === 'chapters' || trackEl.kind === 'metadata') && + trackEl.track.mode === 'disabled' + ) { + trackEl.track.mode = 'hidden'; + } + } + #upgradeProperty(this: typeof nativeElTest, prop: keyof typeof nativeElTest) { // Sets properties that are set before the custom element is upgraded. // https://web.dev/custom-elements-best-practices/#make-properties-lazy diff --git a/packages/core/src/dom/media/hls/hlsjs.ts b/packages/core/src/dom/media/hls/hlsjs.ts index 70cae2d7..fc485e53 100644 --- a/packages/core/src/dom/media/hls/hlsjs.ts +++ b/packages/core/src/dom/media/hls/hlsjs.ts @@ -1,6 +1,7 @@ import Hls, { type HlsConfig } from 'hls.js'; import type { Delegate } from '../../../core/media/delegate'; import { HlsJsMediaErrorsMixin } from './errors'; +import { HlsJsMediaMetadataTracksMixin } from './metadata-tracks'; import { HlsJsMediaPreloadMixin } from './preload'; import { HlsJsMediaTextTracksMixin } from './text-tracks'; @@ -55,5 +56,5 @@ class HlsJsMediaDelegateBase extends EventTarget implements Delegate { } export class HlsJsMediaDelegate extends HlsJsMediaPreloadMixin( - HlsJsMediaTextTracksMixin(HlsJsMediaErrorsMixin(HlsJsMediaDelegateBase)) + HlsJsMediaMetadataTracksMixin(HlsJsMediaTextTracksMixin(HlsJsMediaErrorsMixin(HlsJsMediaDelegateBase))) ) {} diff --git a/packages/core/src/dom/media/hls/metadata-tracks.ts b/packages/core/src/dom/media/hls/metadata-tracks.ts new file mode 100644 index 00000000..2c79060f --- /dev/null +++ b/packages/core/src/dom/media/hls/metadata-tracks.ts @@ -0,0 +1,56 @@ +import type { Constructor } from '@videojs/utils/types'; +import Hls from 'hls.js'; +import type { HlsEngineHost } from './preload'; + +/** + * Ensures user-authored metadata and chapters `` elements stay loaded + * when hls.js is active. + * + * hls.js forcibly clears all cues from text tracks on manifest loads and media + * attaches. This mixin re-enables those tracks by forcing `mode = 'hidden'` + * and reloading the track source when cues have been wiped. + */ +export function HlsJsMediaMetadataTracksMixin>(BaseClass: Base) { + class HlsJsMediaMetadataTracks extends (BaseClass as Constructor) { + constructor(...args: any[]) { + super(...args); + + // Watch out here, AFTER the manifest is loaded! + this.engine?.on(Hls.Events.MANIFEST_LOADED, () => this.#forceHiddenTracks()); + this.engine?.on(Hls.Events.MEDIA_ATTACHED, () => this.#forceHiddenTracks()); + } + + #forceHiddenTracks(): void { + const { target } = this; + if (!target) return; + + [...target.textTracks].forEach((track) => { + if (!(track.kind === 'metadata' || track.kind === 'chapters')) return; + + let selector = 'track'; + if (track.kind) selector += `[kind="${track.kind}"]`; + if (track.label) selector += `[label="${track.label}"]`; + + const trackEl = target.querySelector(selector) as HTMLTrackElement | null; + if (!trackEl) return; + + const src = trackEl.getAttribute('src') ?? ''; + const TRACK_LOADED = 2; + + // Only reset the src attribute if the track was loaded before and had no cues. + if (trackEl.readyState === TRACK_LOADED && !track.cues?.length) { + trackEl.removeAttribute('src'); + setTimeout(() => { + trackEl.setAttribute('src', src); + }, 0); + } + + if (trackEl?.default && track.mode !== 'hidden') { + track.mode = 'hidden'; + } + }); + } + } + + return HlsJsMediaMetadataTracks as unknown as Base; +} diff --git a/packages/core/src/dom/store/features/text-track.ts b/packages/core/src/dom/store/features/text-track.ts index b7ea47ed..45d3b714 100644 --- a/packages/core/src/dom/store/features/text-track.ts +++ b/packages/core/src/dom/store/features/text-track.ts @@ -16,7 +16,6 @@ export const textTrackFeature = definePlayerFeature({ target().media, (track) => track.kind === 'subtitles' || track.kind === 'captions' ); - if (!subtitlesTracks.length) return false; const showing = subtitlesTracks.some((track: TextTrack) => track.mode === 'showing'); @@ -61,11 +60,6 @@ export const textTrackFeature = definePlayerFeature({ } } - // Browsers don't auto-load cues for metadata/chapters tracks even with - // the `default` attribute — mode stays 'disabled' until explicitly set. - if (chaptersTrack && chaptersTrack.mode === 'disabled') chaptersTrack.mode = 'hidden'; - if (thumbnailTrack && thumbnailTrack.mode === 'disabled') thumbnailTrack.mode = 'hidden'; - // VTTCue extends TextTrackCue with `text` — cast via `unknown` since // the CueList is typed as TextTrackCue which doesn't expose `text`. const chaptersCues: MediaTextCue[] = chaptersTrack?.cues @@ -84,7 +78,11 @@ 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') ?? []) { + const tracks = media.querySelectorAll?.('track') ?? []; + // For a CustomMediaElement, the functional tracks are in the shadow root. + const shadowTracks = media.shadowRoot?.querySelectorAll?.('track') ?? []; + + for (const trackEl of [...tracks, ...shadowTracks]) { if (!trackEl.track?.cues?.length) { listen(trackEl, 'load', sync, { signal: trackCleanup.signal }); } diff --git a/site/src/components/docs/demos/thumbnail/html/css/BasicUsage.html b/site/src/components/docs/demos/thumbnail/html/css/BasicUsage.html index 286ccc33..0de7658a 100644 --- a/site/src/components/docs/demos/thumbnail/html/css/BasicUsage.html +++ b/site/src/components/docs/demos/thumbnail/html/css/BasicUsage.html @@ -13,6 +13,7 @@ kind="metadata" label="thumbnails" src="/docs/demos/thumbnail/basic.vtt" + default /> diff --git a/site/src/components/docs/demos/thumbnail/react/css/BasicUsage.tsx b/site/src/components/docs/demos/thumbnail/react/css/BasicUsage.tsx index 698915c1..61634751 100644 --- a/site/src/components/docs/demos/thumbnail/react/css/BasicUsage.tsx +++ b/site/src/components/docs/demos/thumbnail/react/css/BasicUsage.tsx @@ -17,7 +17,7 @@ export default function TextTrackUsage() { playsInline crossOrigin="anonymous" > - + diff --git a/site/src/components/home/HeroVideo.tsx b/site/src/components/home/HeroVideo.tsx index 0ba173b4..f8c25713 100644 --- a/site/src/components/home/HeroVideo.tsx +++ b/site/src/components/home/HeroVideo.tsx @@ -39,6 +39,7 @@ export default function HeroVideo({ kind="metadata" label="thumbnails" src={`https://image.mux.com/${VJS10_DEMO_VIDEO.id}/storyboard.vtt`} + default /> diff --git a/site/src/content/docs/reference/thumbnail.mdx b/site/src/content/docs/reference/thumbnail.mdx index 37805cd9..33dd75cb 100644 --- a/site/src/content/docs/reference/thumbnail.mdx +++ b/site/src/content/docs/reference/thumbnail.mdx @@ -46,6 +46,7 @@ import jsonSpriteHtmlTs from "@/components/docs/demos/thumbnail/html/css/JsonSpr kind="metadata" label="thumbnails" src="https://image.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM/storyboard.vtt" + default /> @@ -59,6 +60,7 @@ import jsonSpriteHtmlTs from "@/components/docs/demos/thumbnail/html/css/JsonSpr kind="metadata" label="thumbnails" src="https://image.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM/storyboard.vtt" + default />