mirror of
https://github.com/zoriya/v10.git
synced 2026-08-05 05:37:21 +00:00
feat(core): add thumbnail component and text track store feature (#643)
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user