diff --git a/packages/core/src/core/index.ts b/packages/core/src/core/index.ts index e753f4a0d..e1f792af8 100644 --- a/packages/core/src/core/index.ts +++ b/packages/core/src/core/index.ts @@ -24,6 +24,10 @@ export * from './ui/slider/slider-data-attrs'; export * from './ui/slider/time-slider-core'; export * from './ui/slider/time-slider-data-attrs'; export * from './ui/slider/volume-slider-core'; +export * from './ui/thumbnail/thumbnail-core'; +export * from './ui/thumbnail/thumbnail-data-attrs'; +export * from './ui/thumbnail/thumbnail-media-fragment'; +export * from './ui/thumbnail/types'; export * from './ui/time/time-core'; export * from './ui/time/time-data-attrs'; export * from './ui/types'; diff --git a/packages/core/src/core/media/state.ts b/packages/core/src/core/media/state.ts index ee4ec1ed6..6c7ae7788 100644 --- a/packages/core/src/core/media/state.ts +++ b/packages/core/src/core/media/state.ts @@ -191,6 +191,21 @@ export interface MediaPlaybackRateState { setPlaybackRate(rate: number): void; } +export interface MediaTextCue { + startTime: number; + endTime: number; + text: string; +} + +export interface MediaTextTrackState { + /** Cues from the first `kind="chapters"` track. */ + chaptersCues: MediaTextCue[]; + /** Cues from the first `kind="metadata" label="thumbnails"` track. */ + thumbnailCues: MediaTextCue[]; + /** The `` element's `src` for resolving relative cue text URLs. */ + thumbnailTrackSrc: string | null; +} + export interface MediaPictureInPictureState { /** * Whether picture-in-picture mode is currently active. diff --git a/packages/core/src/core/ui/thumbnail/tests/thumbnail-core.test.ts b/packages/core/src/core/ui/thumbnail/tests/thumbnail-core.test.ts new file mode 100644 index 000000000..da4a7fb06 --- /dev/null +++ b/packages/core/src/core/ui/thumbnail/tests/thumbnail-core.test.ts @@ -0,0 +1,342 @@ +import { describe, expect, it } from 'vitest'; + +import { ThumbnailCore } from '../thumbnail-core'; +import type { ThumbnailImage } from '../types'; + +function createImage(overrides: Partial = {}): ThumbnailImage { + return { + url: 'sprite.jpg', + startTime: 0, + endTime: 5, + width: 256, + height: 160, + coords: { x: 0, y: 0 }, + ...overrides, + }; +} + +function createTimeline(count: number, interval = 5): ThumbnailImage[] { + const images: ThumbnailImage[] = []; + const cols = 10; + const tileW = 256; + const tileH = 160; + + for (let i = 0; i < count; i++) { + const col = i % cols; + const row = Math.floor(i / cols); + images.push( + createImage({ + startTime: i * interval, + endTime: (i + 1) * interval, + coords: { x: col * tileW, y: row * tileH }, + }) + ); + } + + return images; +} + +describe('ThumbnailCore', () => { + describe('findActiveThumbnail', () => { + it('returns undefined for empty array', () => { + const core = new ThumbnailCore(); + expect(core.findActiveThumbnail([], 5)).toBeUndefined(); + }); + + it('finds the first thumbnail at time 0', () => { + const core = new ThumbnailCore(); + const images = createTimeline(3); + + expect(core.findActiveThumbnail(images, 0)).toBe(images[0]); + }); + + it('finds thumbnail matching the exact start time', () => { + const core = new ThumbnailCore(); + const images = createTimeline(3); + + expect(core.findActiveThumbnail(images, 5)).toBe(images[1]); + expect(core.findActiveThumbnail(images, 10)).toBe(images[2]); + }); + + it('finds thumbnail within a time range', () => { + const core = new ThumbnailCore(); + const images = createTimeline(3); + + expect(core.findActiveThumbnail(images, 2.5)).toBe(images[0]); + expect(core.findActiveThumbnail(images, 7)).toBe(images[1]); + expect(core.findActiveThumbnail(images, 12)).toBe(images[2]); + }); + + it('clamps to last thumbnail for time past all end times', () => { + const core = new ThumbnailCore(); + const images = createTimeline(3); + + expect(core.findActiveThumbnail(images, 15)).toBe(images[2]); + expect(core.findActiveThumbnail(images, 100)).toBe(images[2]); + }); + + it('returns undefined for negative time', () => { + const core = new ThumbnailCore(); + const images = createTimeline(3); + + expect(core.findActiveThumbnail(images, -1)).toBeUndefined(); + }); + + it('handles thumbnails without endTime', () => { + const core = new ThumbnailCore(); + const images: ThumbnailImage[] = [ + { url: 'sprite.jpg', startTime: 0, width: 256, height: 160, coords: { x: 0, y: 0 } }, + { url: 'sprite.jpg', startTime: 5, width: 256, height: 160, coords: { x: 0, y: 0 } }, + { url: 'sprite.jpg', startTime: 10, width: 256, height: 160, coords: { x: 0, y: 0 } }, + ]; + + expect(core.findActiveThumbnail(images, 0)).toBe(images[0]); + expect(core.findActiveThumbnail(images, 7)).toBe(images[1]); + expect(core.findActiveThumbnail(images, 999)).toBe(images[2]); + }); + + it('handles large datasets efficiently', () => { + const core = new ThumbnailCore(); + const images = createTimeline(1000, 1); + + const result = core.findActiveThumbnail(images, 500.5); + + expect(result).toBe(images[500]); + }); + }); + + describe('calculateScale', () => { + it('returns 1 when no constraints apply', () => { + const core = new ThumbnailCore(); + + expect( + core.calculateScale(256, 160, { minWidth: 0, maxWidth: Infinity, minHeight: 0, maxHeight: Infinity }) + ).toBe(1); + }); + + it('scales down to fit max-width', () => { + const core = new ThumbnailCore(); + + const scale = core.calculateScale(256, 160, { + minWidth: 0, + maxWidth: 128, + minHeight: 0, + maxHeight: Infinity, + }); + + expect(scale).toBe(0.5); + }); + + it('scales down to fit max-height', () => { + const core = new ThumbnailCore(); + + const scale = core.calculateScale(256, 160, { + minWidth: 0, + maxWidth: Infinity, + minHeight: 0, + maxHeight: 80, + }); + + expect(scale).toBe(0.5); + }); + + it('uses the smaller ratio when both max constraints apply', () => { + const core = new ThumbnailCore(); + + const scale = core.calculateScale(256, 160, { + minWidth: 0, + maxWidth: 128, + minHeight: 0, + maxHeight: 40, + }); + + // min(128/256, 40/160) = min(0.5, 0.25) = 0.25 + expect(scale).toBe(0.25); + }); + + it('scales up to meet min-width', () => { + const core = new ThumbnailCore(); + + const scale = core.calculateScale(100, 50, { + minWidth: 200, + maxWidth: Infinity, + minHeight: 0, + maxHeight: Infinity, + }); + + expect(scale).toBe(2); + }); + + it('does not scale when tile already fits within constraints', () => { + const core = new ThumbnailCore(); + + const scale = core.calculateScale(256, 160, { + minWidth: 0, + maxWidth: 512, + minHeight: 0, + maxHeight: 320, + }); + + expect(scale).toBe(1); + }); + }); + + describe('resize', () => { + it('returns container and image dimensions for sprite thumbnail', () => { + const core = new ThumbnailCore(); + const thumbnail = createImage({ coords: { x: 512, y: 320 } }); + const noConstraints = { minWidth: 0, maxWidth: Infinity, minHeight: 0, maxHeight: Infinity }; + + const result = core.resize(thumbnail, 2560, 1600, noConstraints); + + expect(result).toEqual({ + scale: 1, + containerWidth: 256, + containerHeight: 160, + imageWidth: 2560, + imageHeight: 1600, + offsetX: 512, + offsetY: 320, + }); + }); + + it('returns zero offsets for first tile', () => { + const core = new ThumbnailCore(); + const thumbnail = createImage({ coords: { x: 0, y: 0 } }); + const noConstraints = { minWidth: 0, maxWidth: Infinity, minHeight: 0, maxHeight: Infinity }; + + const result = core.resize(thumbnail, 2560, 1600, noConstraints); + + expect(result).toMatchObject({ offsetX: 0, offsetY: 0 }); + }); + + it('scales all dimensions uniformly when constrained', () => { + const core = new ThumbnailCore(); + const thumbnail = createImage({ coords: { x: 512, y: 320 } }); + + const result = core.resize(thumbnail, 2560, 1600, { + minWidth: 0, + maxWidth: 128, + minHeight: 0, + maxHeight: Infinity, + }); + + // scale = 128/256 = 0.5 + expect(result).toEqual({ + scale: 0.5, + containerWidth: 128, + containerHeight: 80, + imageWidth: 1280, + imageHeight: 800, + offsetX: 256, + offsetY: 160, + }); + }); + + it('handles individual images without coords', () => { + const core = new ThumbnailCore(); + const thumbnail: ThumbnailImage = { url: 'thumb.jpg', startTime: 0, endTime: 5 }; + const noConstraints = { minWidth: 0, maxWidth: Infinity, minHeight: 0, maxHeight: Infinity }; + + const result = core.resize(thumbnail, 320, 180, noConstraints); + + expect(result).toEqual({ + scale: 1, + containerWidth: 320, + containerHeight: 180, + imageWidth: 320, + imageHeight: 180, + offsetX: 0, + offsetY: 0, + }); + }); + + it('returns undefined when dimensions are unavailable', () => { + const core = new ThumbnailCore(); + const thumbnail: ThumbnailImage = { url: 'thumb.jpg', startTime: 0, endTime: 5 }; + + expect( + core.resize(thumbnail, 0, 0, { minWidth: 0, maxWidth: Infinity, minHeight: 0, maxHeight: Infinity }) + ).toBeUndefined(); + }); + }); + + describe('getState', () => { + it('returns loading state', () => { + const core = new ThumbnailCore(); + const state = core.getState(true, false, undefined); + + expect(state).toEqual({ loading: true, error: false, hidden: false }); + }); + + it('returns error state', () => { + const core = new ThumbnailCore(); + const state = core.getState(false, true, undefined); + + expect(state).toEqual({ loading: false, error: true, hidden: true }); + }); + + it('returns hidden when no thumbnail and not loading', () => { + const core = new ThumbnailCore(); + const state = core.getState(false, false, undefined); + + expect(state).toEqual({ loading: false, error: false, hidden: true }); + }); + + it('returns visible when thumbnail exists', () => { + const core = new ThumbnailCore(); + const state = core.getState(false, false, createImage()); + + expect(state).toEqual({ loading: false, error: false, hidden: false }); + }); + }); + + describe('getAttrs', () => { + it('returns role img and aria-hidden', () => { + const core = new ThumbnailCore(); + const state = core.getState(false, false, createImage()); + const attrs = core.getAttrs(state); + + expect(attrs.role).toBe('img'); + expect(attrs['aria-hidden']).toBe('true'); + }); + }); + + describe('parseConstraints', () => { + it('parses valid numeric strings', () => { + const core = new ThumbnailCore(); + const result = core.parseConstraints({ + minWidth: '100px', + maxWidth: '200px', + minHeight: '50px', + maxHeight: '150px', + }); + + expect(result).toEqual({ minWidth: 100, maxWidth: 200, minHeight: 50, maxHeight: 150 }); + }); + + it('defaults non-finite values to 0 for min and Infinity for max', () => { + const core = new ThumbnailCore(); + const result = core.parseConstraints({ + minWidth: 'none', + maxWidth: 'none', + minHeight: '', + maxHeight: '', + }); + + expect(result).toEqual({ minWidth: 0, maxWidth: Infinity, minHeight: 0, maxHeight: Infinity }); + }); + + it('handles mixed valid and invalid values', () => { + const core = new ThumbnailCore(); + const result = core.parseConstraints({ + minWidth: '0px', + maxWidth: '128px', + minHeight: 'none', + maxHeight: 'none', + }); + + expect(result).toEqual({ minWidth: 0, maxWidth: 128, minHeight: 0, maxHeight: Infinity }); + }); + }); +}); diff --git a/packages/core/src/core/ui/thumbnail/tests/thumbnail-media-fragment.test.ts b/packages/core/src/core/ui/thumbnail/tests/thumbnail-media-fragment.test.ts new file mode 100644 index 000000000..b41be9085 --- /dev/null +++ b/packages/core/src/core/ui/thumbnail/tests/thumbnail-media-fragment.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; + +import { mapCuesToThumbnails, parseMediaFragment } from '../thumbnail-media-fragment'; + +describe('parseMediaFragment', () => { + it('parses url#xywh=x,y,w,h', () => { + const result = parseMediaFragment('sprite.jpg#xywh=0,0,256,160'); + + expect(result.url).toBe('sprite.jpg'); + expect(result.width).toBe(256); + expect(result.height).toBe(160); + expect(result.coords).toEqual({ x: 0, y: 0 }); + }); + + it('returns url without fragment when no hash present', () => { + const result = parseMediaFragment('thumb-001.jpg'); + + expect(result.url).toBe('thumb-001.jpg'); + expect(result.width).toBeUndefined(); + expect(result.height).toBeUndefined(); + expect(result.coords).toBeUndefined(); + }); + + it('resolves relative URLs against baseURL', () => { + const result = parseMediaFragment('sprite.jpg#xywh=0,0,256,160', 'https://cdn.example.com/media/thumbnails.vtt'); + + expect(result.url).toBe('https://cdn.example.com/media/sprite.jpg'); + }); + + it('handles absolute URLs in cue text', () => { + const result = parseMediaFragment( + 'https://cdn.example.com/sprite.jpg#xywh=0,0,256,160', + 'https://other.com/thumbnails.vtt' + ); + + expect(result.url).toBe('https://cdn.example.com/sprite.jpg'); + }); + + it('handles hash without key=value', () => { + const result = parseMediaFragment('sprite.jpg#fragment'); + + expect(result.url).toBe('sprite.jpg'); + expect(result.coords).toBeUndefined(); + }); + + it('parses non-zero coordinates', () => { + const result = parseMediaFragment('sprite.jpg#xywh=512,320,256,160'); + + expect(result.coords).toEqual({ x: 512, y: 320 }); + expect(result.width).toBe(256); + expect(result.height).toBe(160); + }); +}); + +describe('mapCuesToThumbnails', () => { + it('maps text cues to ThumbnailImage array', () => { + const cues = [ + { startTime: 0, endTime: 5, text: 'sprite.jpg#xywh=0,0,256,160' }, + { startTime: 5, endTime: 10, text: 'sprite.jpg#xywh=256,0,256,160' }, + ]; + + const images = mapCuesToThumbnails(cues); + + expect(images).toHaveLength(2); + expect(images[0]).toEqual({ + url: 'sprite.jpg', + startTime: 0, + endTime: 5, + width: 256, + height: 160, + coords: { x: 0, y: 0 }, + }); + expect(images[1]!.coords).toEqual({ x: 256, y: 0 }); + }); + + it('resolves relative URLs against baseURL', () => { + const cues = [{ startTime: 0, endTime: 5, text: 'sprite.jpg#xywh=0,0,256,160' }]; + + const images = mapCuesToThumbnails(cues, 'https://cdn.example.com/media/thumbnails.vtt'); + + expect(images[0]!.url).toBe('https://cdn.example.com/media/sprite.jpg'); + }); + + it('handles individual images without fragments', () => { + const cues = [ + { startTime: 0, endTime: 5, text: 'thumb-001.jpg' }, + { startTime: 5, endTime: 10, text: 'thumb-002.jpg' }, + ]; + + const images = mapCuesToThumbnails(cues); + + expect(images).toHaveLength(2); + expect(images[0]!.url).toBe('thumb-001.jpg'); + expect(images[0]!.coords).toBeUndefined(); + }); +}); diff --git a/packages/core/src/core/ui/thumbnail/thumbnail-core.ts b/packages/core/src/core/ui/thumbnail/thumbnail-core.ts new file mode 100644 index 000000000..f4a3f7049 --- /dev/null +++ b/packages/core/src/core/ui/thumbnail/thumbnail-core.ts @@ -0,0 +1,150 @@ +import type { + ThumbnailConstraints, + ThumbnailCrossOrigin, + ThumbnailFetchPriority, + ThumbnailImage, + ThumbnailLoading, + ThumbnailResizeResult, +} from './types'; + +export interface ThumbnailProps { + /** Time in seconds to display the thumbnail for. */ + time?: number | undefined; + /** CORS setting forwarded to the inner ``. */ + crossOrigin?: ThumbnailCrossOrigin | undefined; + /** Image loading strategy forwarded to the inner ``. */ + loading?: ThumbnailLoading | undefined; + /** Image fetch priority hint forwarded to the inner ``. */ + fetchPriority?: ThumbnailFetchPriority | undefined; +} + +export interface ThumbnailState { + /** The thumbnail image is loading. */ + loading: boolean; + /** The thumbnail image failed to load. */ + error: boolean; + /** No thumbnail is available and not loading — the component should be hidden. */ + hidden: boolean; +} + +export class ThumbnailCore { + findActiveThumbnail(thumbnails: ThumbnailImage[], time: number): ThumbnailImage | undefined { + if (thumbnails.length === 0) return undefined; + + let low = 0; + let high = thumbnails.length - 1; + let result: ThumbnailImage | undefined; + + while (low <= high) { + const mid = (low + high) >>> 1; + const image = thumbnails[mid]!; + + if (time >= image.startTime) { + result = image; + low = mid + 1; + } else { + high = mid - 1; + } + } + + return result; + } + + /** + * Parse CSS constraint strings into numeric `ThumbnailConstraints`. + * + * Accepts any object with string `minWidth`/`maxWidth`/`minHeight`/`maxHeight` + * properties — `CSSStyleDeclaration` satisfies this structurally. + */ + parseConstraints(raw: { + minWidth: string; + maxWidth: string; + minHeight: string; + maxHeight: string; + }): ThumbnailConstraints { + const minW = parseFloat(raw.minWidth); + const maxW = parseFloat(raw.maxWidth); + const minH = parseFloat(raw.minHeight); + const maxH = parseFloat(raw.maxHeight); + + return { + minWidth: Number.isFinite(minW) ? minW : 0, + maxWidth: Number.isFinite(maxW) ? maxW : Infinity, + minHeight: Number.isFinite(minH) ? minH : 0, + maxHeight: Number.isFinite(maxH) ? maxH : Infinity, + }; + } + + /** + * Calculate a uniform scale factor that fits `tileWidth × tileHeight` within the + * given CSS min/max constraints while preserving aspect ratio. + * + * - Scales down when the tile exceeds max constraints. + * - Scales up when the tile is smaller than min constraints. + * - Returns `1` when no scaling is needed. + */ + calculateScale(tileWidth: number, tileHeight: number, constraints: ThumbnailConstraints): number { + const { minWidth, maxWidth, minHeight, maxHeight } = constraints; + + const maxRatio = Math.min(maxWidth / tileWidth, maxHeight / tileHeight); + const minRatio = Math.max(minWidth / tileWidth, minHeight / tileHeight); + + // Scale down if exceeding max constraints. + if (Number.isFinite(maxRatio) && maxRatio < 1) return maxRatio; + // Scale up if below min constraints. + if (Number.isFinite(minRatio) && minRatio > 1) return minRatio; + + return 1; + } + + /** + * Compute container and image dimensions for the current thumbnail, scaled to + * fit within the element's CSS min/max constraints. + * + * The container clips the sprite sheet via `overflow: hidden`, and the image is + * positioned with `transform: translate()` to show the correct tile. + */ + resize( + thumbnail: ThumbnailImage, + imgNaturalWidth: number, + imgNaturalHeight: number, + constraints: ThumbnailConstraints + ): ThumbnailResizeResult | undefined { + const tileWidth = thumbnail.width ?? imgNaturalWidth; + const tileHeight = thumbnail.height ?? imgNaturalHeight; + + if (!tileWidth || !tileHeight) return undefined; + + const scale = this.calculateScale(tileWidth, tileHeight, constraints); + + return { + scale, + containerWidth: tileWidth * scale, + containerHeight: tileHeight * scale, + imageWidth: imgNaturalWidth * scale, + imageHeight: imgNaturalHeight * scale, + offsetX: (thumbnail.coords?.x ?? 0) * scale, + offsetY: (thumbnail.coords?.y ?? 0) * scale, + }; + } + + getState(loading: boolean, error: boolean, thumbnail: ThumbnailImage | undefined): ThumbnailState { + return { + loading, + error, + hidden: !loading && !thumbnail, + }; + } + + getAttrs(_state: ThumbnailState) { + return { + role: 'img' as const, + 'aria-hidden': 'true' as const, + }; + } +} + +export namespace ThumbnailCore { + export type Props = ThumbnailProps; + export type State = ThumbnailState; +} diff --git a/packages/core/src/core/ui/thumbnail/thumbnail-data-attrs.ts b/packages/core/src/core/ui/thumbnail/thumbnail-data-attrs.ts new file mode 100644 index 000000000..1bb851675 --- /dev/null +++ b/packages/core/src/core/ui/thumbnail/thumbnail-data-attrs.ts @@ -0,0 +1,8 @@ +import type { StateAttrMap } from '../types'; +import type { ThumbnailState } from './thumbnail-core'; + +export const ThumbnailDataAttrs = { + loading: 'data-loading', + error: 'data-error', + hidden: 'data-hidden', +} as const satisfies StateAttrMap; diff --git a/packages/core/src/core/ui/thumbnail/thumbnail-media-fragment.ts b/packages/core/src/core/ui/thumbnail/thumbnail-media-fragment.ts new file mode 100644 index 000000000..27fbfea28 --- /dev/null +++ b/packages/core/src/core/ui/thumbnail/thumbnail-media-fragment.ts @@ -0,0 +1,71 @@ +import { isNumber } from '@videojs/utils/predicate'; + +import type { MediaTextCue } from '../../media/state'; +import type { ThumbnailCoords, ThumbnailImage } from './types'; + +/** Parse `url#xywh=x,y,w,h` into a URL and optional sprite coordinates. */ +export function parseMediaFragment( + text: string, + baseURL?: string +): { + url: string; + width?: number; + height?: number; + coords?: ThumbnailCoords; +} { + const parts = text.trim().split('#'); + const rawURL = parts[0] ?? ''; + const hash = parts[1]; + + const url = baseURL ? new URL(rawURL, baseURL).href : rawURL; + + if (!hash) return { url }; + + const eqIndex = hash.indexOf('='); + if (eqIndex === -1) return { url }; + + const keys = hash.slice(0, eqIndex); + const values = hash + .slice(eqIndex + 1) + .split(',') + .map(Number); + + const data: Record = {}; + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + const value = values[i]; + if (key && isNumber(value) && !Number.isNaN(value)) { + data[key] = value; + } + } + + const result: { url: string; width?: number; height?: number; coords?: ThumbnailCoords } = { url }; + + if (isNumber(data.w)) result.width = data.w; + if (isNumber(data.h)) result.height = data.h; + if (isNumber(data.x) && isNumber(data.y)) result.coords = { x: data.x, y: data.y }; + + return result; +} + +/** + * Convert an array of text cues (e.g. `VTTCue` from a `` element) + * into {@link ThumbnailImage} entries by parsing the media-fragment in + * each cue's text. + */ +export function mapCuesToThumbnails(cues: MediaTextCue[], baseURL?: string): ThumbnailImage[] { + const images: ThumbnailImage[] = []; + + for (const cue of cues) { + const fragment = parseMediaFragment(cue.text, baseURL); + const image: ThumbnailImage = { url: fragment.url, startTime: cue.startTime, endTime: cue.endTime }; + + if (fragment.width) image.width = fragment.width; + if (fragment.height) image.height = fragment.height; + if (fragment.coords) image.coords = fragment.coords; + + images.push(image); + } + + return images; +} diff --git a/packages/core/src/core/ui/thumbnail/types.ts b/packages/core/src/core/ui/thumbnail/types.ts new file mode 100644 index 000000000..9aac5c1b8 --- /dev/null +++ b/packages/core/src/core/ui/thumbnail/types.ts @@ -0,0 +1,38 @@ +export interface ThumbnailCoords { + x: number; + y: number; +} + +export interface ThumbnailImage { + url: string; + startTime: number; + endTime?: number; + width?: number; + height?: number; + coords?: ThumbnailCoords; +} + +export type ThumbnailSrc = string | ThumbnailImage[] | null; + +export type ThumbnailCrossOrigin = 'anonymous' | 'use-credentials' | '' | null; + +export type ThumbnailLoading = 'eager' | 'lazy'; + +export type ThumbnailFetchPriority = 'high' | 'low' | 'auto'; + +export interface ThumbnailConstraints { + minWidth: number; + maxWidth: number; + minHeight: number; + maxHeight: number; +} + +export interface ThumbnailResizeResult { + scale: number; + containerWidth: number; + containerHeight: number; + imageWidth: number; + imageHeight: number; + offsetX: number; + offsetY: number; +} diff --git a/packages/core/src/dom/index.ts b/packages/core/src/dom/index.ts index 47f4a54f9..4f8559463 100644 --- a/packages/core/src/dom/index.ts +++ b/packages/core/src/dom/index.ts @@ -6,4 +6,5 @@ export * from './ui/button'; export * from './ui/event'; export * from './ui/slider'; export * from './ui/slider-css-vars'; +export * from './ui/thumbnail'; export * from './utils'; diff --git a/packages/core/src/dom/media/types.ts b/packages/core/src/dom/media/types.ts index 61ccb8e58..4a7b521cc 100644 --- a/packages/core/src/dom/media/types.ts +++ b/packages/core/src/dom/media/types.ts @@ -7,6 +7,7 @@ import type { MediaPlaybackRateState, MediaPlaybackState, MediaSourceState, + MediaTextTrackState, MediaTimeState, MediaVolumeState, } from '../../core/media/state'; @@ -53,6 +54,7 @@ export type VideoFeatures = [ PlayerFeature, PlayerFeature, PlayerFeature, + PlayerFeature, ]; export type AudioFeatures = [ diff --git a/packages/core/src/dom/store/features/feature.parts.ts b/packages/core/src/dom/store/features/feature.parts.ts index 36feabafe..ef36e2781 100644 --- a/packages/core/src/dom/store/features/feature.parts.ts +++ b/packages/core/src/dom/store/features/feature.parts.ts @@ -6,6 +6,7 @@ import { pipFeature } from './pip'; import { playbackFeature } from './playback'; import { playbackRateFeature } from './playback-rate'; import { sourceFeature } from './source'; +import { textTrackFeature } from './text-track'; import { timeFeature } from './time'; import { volumeFeature } from './volume'; @@ -18,6 +19,7 @@ export { playbackFeature as playback, playbackRateFeature as playbackRate, sourceFeature as source, + textTrackFeature as textTrack, timeFeature as time, volumeFeature as volume, }; @@ -32,6 +34,7 @@ export const video: VideoFeatures = [ fullscreenFeature, pipFeature, controlsFeature, + textTrackFeature, ]; export const audio: AudioFeatures = [ diff --git a/packages/core/src/dom/store/features/index.ts b/packages/core/src/dom/store/features/index.ts index f303e71ac..e2a0fa2c2 100644 --- a/packages/core/src/dom/store/features/index.ts +++ b/packages/core/src/dom/store/features/index.ts @@ -6,5 +6,6 @@ export * from './pip'; export * from './playback'; export * from './playback-rate'; export * from './source'; +export * from './text-track'; export * from './time'; export * from './volume'; 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 new file mode 100644 index 000000000..b7f027686 --- /dev/null +++ b/packages/core/src/dom/store/features/tests/text-track.test.ts @@ -0,0 +1,138 @@ +import { createStore } from '@videojs/store'; +import { describe, expect, it } from 'vitest'; + +import type { PlayerTarget } from '../../../media/types'; +import { textTrackFeature } from '../text-track'; + +/** + * jsdom's TextTrackList does not implement EventTarget (no addEventListener/ + * dispatchEvent), so `listen(media.textTracks, ...)` throws. The store's + * error boundary catches this, but we can't dispatch textTracks events in + * tests. We test what we can: initial state, track detection via `addTextTrack`, + * and `loadstart` resync (dispatched on media, which works). + */ + +function createVideo(): HTMLVideoElement { + return document.createElement('video'); +} + +describe('textTrackFeature', () => { + describe('initial state', () => { + it('has empty initial state', () => { + const video = createVideo(); + const store = createStore()(textTrackFeature); + store.attach({ media: video, container: null }); + + expect(store.state.chaptersCues).toEqual([]); + expect(store.state.thumbnailCues).toEqual([]); + expect(store.state.thumbnailTrackSrc).toBeNull(); + }); + }); + + describe('attach', () => { + it('detects chapters track via addTextTrack', () => { + const video = createVideo(); + video.addTextTrack('chapters', 'Chapters', 'en'); + + const store = createStore()(textTrackFeature); + store.attach({ media: video, container: null }); + + // Track detected, but no cues in jsdom + expect(store.state.chaptersCues).toEqual([]); + }); + + it('detects thumbnail track by kind and label', () => { + const video = createVideo(); + video.addTextTrack('metadata', 'thumbnails', 'en'); + + const store = createStore()(textTrackFeature); + store.attach({ media: video, container: null }); + + // Track detected, but no cues or element for src + expect(store.state.thumbnailCues).toEqual([]); + expect(store.state.thumbnailTrackSrc).toBeNull(); + }); + + it('ignores metadata tracks without thumbnails label', () => { + const video = createVideo(); + video.addTextTrack('metadata', 'ad-cues', 'en'); + + const store = createStore()(textTrackFeature); + store.attach({ media: video, container: null }); + + expect(store.state.thumbnailCues).toEqual([]); + expect(store.state.thumbnailTrackSrc).toBeNull(); + }); + + it('prefers first matching track when multiple exist', () => { + const video = createVideo(); + video.addTextTrack('chapters', 'Ch1', 'en'); + video.addTextTrack('chapters', 'Ch2', 'fr'); + video.addTextTrack('metadata', 'thumbnails', 'en'); + video.addTextTrack('metadata', 'thumbnails', 'fr'); + + const store = createStore()(textTrackFeature); + store.attach({ media: video, container: null }); + + // Should not error with multiple matching tracks + expect(store.state.chaptersCues).toEqual([]); + expect(store.state.thumbnailCues).toEqual([]); + }); + + it('resyncs on loadstart event', () => { + const video = createVideo(); + + const store = createStore()(textTrackFeature); + store.attach({ media: video, container: null }); + + // Add a track programmatically (won't trigger textTracks event in jsdom) + video.addTextTrack('metadata', 'thumbnails', 'en'); + + // Dispatch loadstart to trigger resync + video.dispatchEvent(new Event('loadstart')); + + // After loadstart, the new track should be detected + expect(store.state.thumbnailCues).toEqual([]); + expect(store.state.thumbnailTrackSrc).toBeNull(); + }); + + it('resolves thumbnailTrackSrc from track element', () => { + const video = createVideo(); + const trackEl = document.createElement('track'); + trackEl.kind = 'metadata'; + trackEl.label = 'thumbnails'; + trackEl.src = 'https://cdn.example.com/thumbnails.vtt'; + trackEl.default = true; + video.appendChild(trackEl); + + // In jsdom, appending to