feat(core): add thumbnail component and text track store feature (#643)

This commit is contained in:
rahim
2026-02-27 15:19:11 -08:00
committed by GitHub
parent 2581699877
commit 7bae887920
20 changed files with 1488 additions and 0 deletions
+4
View File
@@ -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';
+15
View File
@@ -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 `<track>` element's `src` for resolving relative cue text URLs. */
thumbnailTrackSrc: string | null;
}
export interface MediaPictureInPictureState {
/**
* Whether picture-in-picture mode is currently active.
@@ -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> = {}): 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 });
});
});
});
@@ -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();
});
});
@@ -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 `<img>`. */
crossOrigin?: ThumbnailCrossOrigin | undefined;
/** Image loading strategy forwarded to the inner `<img>`. */
loading?: ThumbnailLoading | undefined;
/** Image fetch priority hint forwarded to the inner `<img>`. */
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;
}
@@ -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<ThumbnailState>;
@@ -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<string, number> = {};
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 `<track>` 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;
}
@@ -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;
}
+1
View File
@@ -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';
+2
View File
@@ -7,6 +7,7 @@ import type {
MediaPlaybackRateState,
MediaPlaybackState,
MediaSourceState,
MediaTextTrackState,
MediaTimeState,
MediaVolumeState,
} from '../../core/media/state';
@@ -53,6 +54,7 @@ export type VideoFeatures = [
PlayerFeature<MediaFullscreenState>,
PlayerFeature<MediaPictureInPictureState>,
PlayerFeature<MediaControlsState>,
PlayerFeature<MediaTextTrackState>,
];
export type AudioFeatures = [
@@ -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 = [
@@ -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';
@@ -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<PlayerTarget>()(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<PlayerTarget>()(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<PlayerTarget>()(textTrackFeature);
store.attach({ media: video, container: null });
// Track detected, but no cues or <track> 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<PlayerTarget>()(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<PlayerTarget>()(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<PlayerTarget>()(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 <track> to <video> adds to textTracks.
// The track.track property links the element to its TextTrack.
const store = createStore<PlayerTarget>()(textTrackFeature);
store.attach({ media: video, container: null });
// findTrackElement maps TextTrack → <track> element → src
// jsdom's TextTrack from <track> may or may not match addTextTrack
// entries, so check if the src was resolved.
// Note: jsdom support for this varies; the feature is validated
// in real browsers via Playwright.
if (store.state.thumbnailTrackSrc !== null) {
expect(store.state.thumbnailTrackSrc).toBe('https://cdn.example.com/thumbnails.vtt');
}
});
it('stops updating after destroy', () => {
const video = createVideo();
const store = createStore<PlayerTarget>()(textTrackFeature);
store.attach({ media: video, container: null });
store.destroy();
// Add tracks after destroy
video.addTextTrack('metadata', 'thumbnails', 'en');
video.dispatchEvent(new Event('loadstart'));
// State remains at defaults
expect(store.state.thumbnailTrackSrc).toBeNull();
});
});
});
@@ -0,0 +1,67 @@
import { findTrackElement, listen } from '@videojs/utils/dom';
import type { MediaTextCue, MediaTextTrackState } from '../../../core/media/state';
import { definePlayerFeature } from '../../feature';
export const textTrackFeature = definePlayerFeature({
state: (): MediaTextTrackState => ({
chaptersCues: [],
thumbnailCues: [],
thumbnailTrackSrc: null,
}),
attach({ target, signal, set }) {
const { media } = target;
let trackCleanup: AbortController | null = null;
function sync() {
trackCleanup?.abort();
trackCleanup = new AbortController();
let chaptersTrack: TextTrack | null = null;
let thumbnailTrack: TextTrack | null = null;
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;
}
// 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
? (Array.from(chaptersTrack.cues) as unknown as MediaTextCue[])
: [];
const thumbnailCues: MediaTextCue[] = thumbnailTrack?.cues
? (Array.from(thumbnailTrack.cues) as unknown as MediaTextCue[])
: [];
let thumbnailTrackSrc: string | null = null;
if (thumbnailTrack) {
const el = findTrackElement(media, thumbnailTrack);
thumbnailTrackSrc = el?.src ?? null;
}
// Listen for <track> load events on tracks that don't have cues yet.
// `addtrack` fires before cues are parsed — we need the `load` event
// on the <track> element to know when cues are ready.
for (const trackEl of media.querySelectorAll('track')) {
if (!trackEl.track?.cues?.length) {
listen(trackEl, 'load', sync, { signal: trackCleanup.signal });
}
}
set({ chaptersCues, thumbnailCues, thumbnailTrackSrc });
}
sync();
listen(media.textTracks, 'addtrack', sync, { signal });
listen(media.textTracks, 'removetrack', sync, { signal });
listen(media.textTracks, 'change', sync, { signal });
listen(media, 'loadstart', sync, { signal });
signal.addEventListener('abort', () => trackCleanup?.abort(), { once: true });
},
});
+3
View File
@@ -7,6 +7,7 @@ import { pipFeature } from './features/pip';
import { playbackFeature } from './features/playback';
import { playbackRateFeature } from './features/playback-rate';
import { sourceFeature } from './features/source';
import { textTrackFeature } from './features/text-track';
import { timeFeature } from './features/time';
import { volumeFeature } from './features/volume';
@@ -24,6 +25,8 @@ export const selectPlayback = createSelector(playbackFeature);
export const selectPlaybackRate = createSelector(playbackRateFeature);
/** Select the source state (src, type). */
export const selectSource = createSelector(sourceFeature);
/** Select the text track state (chapters cues, thumbnail cues). */
export const selectTextTrack = createSelector(textTrackFeature);
/** Select the time state (currentTime, duration, seek). */
export const selectTime = createSelector(timeFeature);
/** Select the volume state (volume, muted, setVolume, setMuted). */
@@ -0,0 +1,321 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { type CreateThumbnailOptions, createThumbnail } from '../thumbnail';
// --- ResizeObserver stub (jsdom lacks it) ---
class ResizeObserverStub {
callback: ResizeObserverCallback;
static instances: ResizeObserverStub[] = [];
constructor(callback: ResizeObserverCallback) {
this.callback = callback;
ResizeObserverStub.instances.push(this);
}
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
}
vi.stubGlobal('ResizeObserver', ResizeObserverStub);
afterEach(() => {
ResizeObserverStub.instances.length = 0;
});
// --- Helpers ---
function createMockImg(): HTMLImageElement {
const img = document.createElement('img');
Object.defineProperty(img, 'naturalWidth', { value: 2560, writable: true, configurable: true });
Object.defineProperty(img, 'naturalHeight', { value: 1600, writable: true, configurable: true });
return img;
}
function createMockContainer(): HTMLElement {
return document.createElement('div');
}
function createOptions(overrides: Partial<CreateThumbnailOptions> = {}): CreateThumbnailOptions {
return {
getContainer: () => createMockContainer(),
getImg: () => createMockImg(),
onStateChange: vi.fn(),
...overrides,
};
}
describe('createThumbnail', () => {
describe('initial state', () => {
it('starts with loading false and error false', () => {
const handle = createThumbnail(createOptions());
expect(handle.loading).toBe(false);
expect(handle.error).toBe(false);
handle.destroy();
});
it('starts with zero natural dimensions', () => {
const handle = createThumbnail(createOptions());
expect(handle.naturalWidth).toBe(0);
expect(handle.naturalHeight).toBe(0);
handle.destroy();
});
});
describe('updateSrc', () => {
it('sets loading to true when given a new non-empty URL', () => {
const handle = createThumbnail(createOptions());
handle.updateSrc('sprite.jpg');
expect(handle.loading).toBe(true);
expect(handle.error).toBe(false);
handle.destroy();
});
it('does nothing when called with the same URL', () => {
const handle = createThumbnail(createOptions());
handle.updateSrc('sprite.jpg');
expect(handle.loading).toBe(true);
// Simulate load completing.
handle.updateSrc('sprite.jpg');
// Should still be loading — same URL, no-op.
expect(handle.loading).toBe(true);
handle.destroy();
});
it('resets state when URL changes to empty', () => {
const handle = createThumbnail(createOptions());
handle.updateSrc('sprite.jpg');
expect(handle.loading).toBe(true);
handle.updateSrc(undefined);
expect(handle.loading).toBe(false);
expect(handle.error).toBe(false);
expect(handle.naturalWidth).toBe(0);
expect(handle.naturalHeight).toBe(0);
handle.destroy();
});
it('resets loading when URL changes to a different URL', () => {
const handle = createThumbnail(createOptions());
handle.updateSrc('sprite-1.jpg');
expect(handle.loading).toBe(true);
handle.updateSrc('sprite-2.jpg');
expect(handle.loading).toBe(true);
expect(handle.error).toBe(false);
handle.destroy();
});
});
describe('img events', () => {
it('updates natural dimensions and clears loading on img load', () => {
const img = createMockImg();
const onStateChange = vi.fn();
const handle = createThumbnail(
createOptions({
getImg: () => img,
onStateChange,
})
);
handle.updateSrc('sprite.jpg');
expect(handle.loading).toBe(true);
img.dispatchEvent(new Event('load'));
expect(handle.loading).toBe(false);
expect(handle.error).toBe(false);
expect(handle.naturalWidth).toBe(2560);
expect(handle.naturalHeight).toBe(1600);
expect(onStateChange).toHaveBeenCalled();
handle.destroy();
});
it('handles src change: load first image, change src, load second image', () => {
const img = createMockImg();
const onStateChange = vi.fn();
const handle = createThumbnail(
createOptions({
getImg: () => img,
onStateChange,
})
);
// First image loads successfully.
handle.updateSrc('sprite-1.jpg');
img.dispatchEvent(new Event('load'));
expect(handle.loading).toBe(false);
expect(handle.naturalWidth).toBe(2560);
// Src changes — loading resets.
handle.updateSrc('sprite-2.jpg');
expect(handle.loading).toBe(true);
// Second image loads — same listener catches it.
Object.defineProperty(img, 'naturalWidth', { value: 1280, configurable: true });
Object.defineProperty(img, 'naturalHeight', { value: 720, configurable: true });
img.dispatchEvent(new Event('load'));
expect(handle.loading).toBe(false);
expect(handle.naturalWidth).toBe(1280);
expect(handle.naturalHeight).toBe(720);
handle.destroy();
});
it('sets error on img error', () => {
const img = createMockImg();
const onStateChange = vi.fn();
const handle = createThumbnail(
createOptions({
getImg: () => img,
onStateChange,
})
);
handle.updateSrc('bad.jpg');
img.dispatchEvent(new Event('error'));
expect(handle.loading).toBe(false);
expect(handle.error).toBe(true);
expect(onStateChange).toHaveBeenCalled();
handle.destroy();
});
it('stops listening to img events after destroy', () => {
const img = createMockImg();
const onStateChange = vi.fn();
const handle = createThumbnail(
createOptions({
getImg: () => img,
onStateChange,
})
);
// Trigger lazy binding so listeners are actually attached.
handle.updateSrc('sprite.jpg');
handle.destroy();
onStateChange.mockClear();
img.dispatchEvent(new Event('load'));
expect(onStateChange).not.toHaveBeenCalled();
});
});
describe('readConstraints', () => {
it('returns default constraints when no container', () => {
const handle = createThumbnail(
createOptions({
getContainer: () => null,
})
);
const constraints = handle.readConstraints();
expect(constraints).toEqual({
minWidth: 0,
maxWidth: Infinity,
minHeight: 0,
maxHeight: Infinity,
});
handle.destroy();
});
});
describe('connect', () => {
it('binds img and ResizeObserver lazily after elements become available', () => {
let img: HTMLImageElement | null = null;
let container: HTMLElement | null = null;
const onStateChange = vi.fn();
const handle = createThumbnail(
createOptions({
getContainer: () => container,
getImg: () => img,
onStateChange,
})
);
// Before elements exist: no observer created.
expect(ResizeObserverStub.instances).toHaveLength(0);
// Simulate elements becoming available (like React refs after mount).
img = createMockImg();
container = createMockContainer();
handle.connect();
// ResizeObserver should now be set up.
expect(ResizeObserverStub.instances).toHaveLength(1);
expect(ResizeObserverStub.instances[0]!.observe).toHaveBeenCalledWith(container);
handle.destroy();
});
it('detects already-loaded img on connect', () => {
const img = createMockImg();
// Mark the img as already complete (cached image).
Object.defineProperty(img, 'complete', { value: true, configurable: true });
const onStateChange = vi.fn();
const handle = createThumbnail(
createOptions({
getImg: () => img,
onStateChange,
})
);
handle.updateSrc('sprite.jpg');
onStateChange.mockClear();
handle.connect();
expect(handle.loading).toBe(false);
expect(handle.naturalWidth).toBe(2560);
expect(handle.naturalHeight).toBe(1600);
expect(onStateChange).toHaveBeenCalledOnce();
handle.destroy();
});
});
describe('destroy', () => {
it('can be called multiple times safely', () => {
const handle = createThumbnail(createOptions());
handle.destroy();
handle.destroy();
});
});
});
+158
View File
@@ -0,0 +1,158 @@
import { listen } from '@videojs/utils/dom';
import { ThumbnailCore } from '../../core/ui/thumbnail/thumbnail-core';
import type { ThumbnailConstraints } from '../../core/ui/thumbnail/types';
export interface CreateThumbnailOptions {
getContainer: () => HTMLElement | null;
getImg: () => HTMLImageElement | null;
onStateChange: () => void;
}
export interface ThumbnailHandle {
readonly loading: boolean;
readonly error: boolean;
readonly naturalWidth: number;
readonly naturalHeight: number;
readConstraints(): ThumbnailConstraints;
updateSrc(url: string | undefined): void;
connect(): void;
destroy(): void;
}
export function createThumbnail(options: CreateThumbnailOptions): ThumbnailHandle {
const { getContainer, getImg, onStateChange } = options;
const core = new ThumbnailCore();
const abort = new AbortController();
const signal = abort.signal;
let loading = false;
let error = false;
let naturalWidth = 0;
let naturalHeight = 0;
let lastSrc = '';
let imgBound = false;
let resizeObserver: ResizeObserver | null = null;
// --- img event listeners ---
function onImgLoad() {
const img = getImg();
if (img) {
naturalWidth = img.naturalWidth;
naturalHeight = img.naturalHeight;
}
loading = false;
error = false;
onStateChange();
}
function onImgError() {
loading = false;
error = true;
onStateChange();
}
function bindImg(img: HTMLImageElement): void {
listen(img, 'load', onImgLoad, { signal });
listen(img, 'error', onImgError, { signal });
}
// --- Lazy binding ---
function ensureBindings(): void {
if (!imgBound) {
const img = getImg();
if (img) {
bindImg(img);
imgBound = true;
}
}
if (!resizeObserver) {
const container = getContainer();
if (container) {
resizeObserver = new ResizeObserver(onStateChange);
resizeObserver.observe(container);
}
}
}
// --- src tracking ---
function updateSrc(url: string | undefined): void {
ensureBindings();
const src = url ?? '';
if (src === lastSrc) return;
lastSrc = src;
if (src) {
loading = true;
error = false;
} else {
loading = false;
error = false;
naturalWidth = 0;
naturalHeight = 0;
}
}
// --- connect / cleanup ---
function connect(): void {
ensureBindings();
// Handle the case where the img already loaded before listeners were bound
// (e.g., cached image in React where mount happens before useEffect).
const img = getImg();
if (img?.complete && img.naturalWidth > 0 && lastSrc) {
naturalWidth = img.naturalWidth;
naturalHeight = img.naturalHeight;
loading = false;
error = false;
onStateChange();
}
}
function destroy(): void {
abort.abort();
resizeObserver?.disconnect();
resizeObserver = null;
}
return {
get loading() {
return loading;
},
get error() {
return error;
},
get naturalWidth() {
return naturalWidth;
},
get naturalHeight() {
return naturalHeight;
},
readConstraints(): ThumbnailConstraints {
const el = getContainer();
if (!el) {
return { minWidth: 0, maxWidth: Infinity, minHeight: 0, maxHeight: Infinity };
}
return core.parseConstraints(getComputedStyle(el));
},
updateSrc,
connect,
destroy,
};
}
+1
View File
@@ -8,5 +8,6 @@ export { isHTMLAudioElement, isHTMLMediaElement, isHTMLVideoElement } from './pr
export { type RafThrottled, rafThrottle } from './raf-throttle';
export { getSlottedElement, querySlot } from './slotted';
export { supportsAnimationFrame, supportsIdleCallback } from './supports';
export { findTrackElement } from './text-track';
export { serializeTimeRanges } from './time-ranges';
export type { CustomElement, CustomElementCallbacks } from './types';
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest';
import { findTrackElement } from '../text-track';
/**
* jsdom does not create unique TextTrack objects per <track> element,
* so we mock the `.track` property to simulate real browser behavior.
*/
function mockTrackProperty(el: HTMLTrackElement): TextTrack {
const track = {} as TextTrack;
Object.defineProperty(el, 'track', { value: track, configurable: true });
return track;
}
describe('findTrackElement', () => {
it('returns the track element that owns the given TextTrack', () => {
const video = document.createElement('video');
const el = document.createElement('track');
const track = mockTrackProperty(el);
video.appendChild(el);
expect(findTrackElement(video, track)).toBe(el);
});
it('returns null when no track element matches', () => {
const video = document.createElement('video');
const el = document.createElement('track');
mockTrackProperty(el);
video.appendChild(el);
const unmatchedTrack = {} as TextTrack;
expect(findTrackElement(video, unmatchedTrack)).toBeNull();
});
it('returns null when there are no track elements', () => {
const video = document.createElement('video');
const unmatchedTrack = {} as TextTrack;
expect(findTrackElement(video, unmatchedTrack)).toBeNull();
});
it('finds the correct track among multiple track elements', () => {
const video = document.createElement('video');
const captionsEl = document.createElement('track');
const captionsTrack = mockTrackProperty(captionsEl);
video.appendChild(captionsEl);
const thumbnailsEl = document.createElement('track');
const thumbnailsTrack = mockTrackProperty(thumbnailsEl);
video.appendChild(thumbnailsEl);
const chaptersEl = document.createElement('track');
const chaptersTrack = mockTrackProperty(chaptersEl);
video.appendChild(chaptersEl);
expect(findTrackElement(video, thumbnailsTrack)).toBe(thumbnailsEl);
expect(findTrackElement(video, captionsTrack)).toBe(captionsEl);
expect(findTrackElement(video, chaptersTrack)).toBe(chaptersEl);
});
});
+7
View File
@@ -0,0 +1,7 @@
/** Find the `<track>` element that owns the given `TextTrack`. */
export function findTrackElement(media: HTMLMediaElement, track: TextTrack): HTMLTrackElement | null {
for (const el of media.querySelectorAll('track')) {
if (el.track === track) return el;
}
return null;
}