Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion packages/core/src/core/ui/thumbnail/core.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { findLastAtOrBefore } from '@videojs/utils/array';
import { isNull, isUndefined } from '@videojs/utils/predicate';

import type {
ThumbnailConstraints,
Expand All @@ -12,6 +13,11 @@ import type {
export interface ThumbnailProps {
/** Time in seconds to display the thumbnail for. */
time?: number | undefined;
/** Pre-parsed thumbnail images — bypasses the automatic `<track>` detection. */
thumbnails?: ThumbnailImage[] | undefined;
}

export interface ThumbnailImageProps {
/**
* CORS setting forwarded to the inner `<img>`.
*
Expand Down Expand Up @@ -127,6 +133,24 @@ export class ThumbnailCore {
};
}

/**
* Resolve the CORS mode the image should request with.
*
* `null` opts out and drops the attribute. Any other explicit value wins, including `''`, which the CORS-settings
* attribute reads as Anonymous. Otherwise the inherited mode applies, which renderers supply only for
* `<track>`-sourced thumbnails since a list set directly may point at a host unrelated to the media element.
*/
resolveCrossOrigin(
explicit: ThumbnailCrossOrigin | undefined,
inherited: ThumbnailCrossOrigin | undefined
): Exclude<ThumbnailCrossOrigin, null> | undefined {
if (isNull(explicit)) return undefined;

if (!isUndefined(explicit)) return explicit;

return inherited ?? undefined;
}

getState(loading: boolean, error: boolean, thumbnail: ThumbnailImage | undefined): ThumbnailState {
return {
loading,
Expand All @@ -146,6 +170,8 @@ export class ThumbnailCore {
}

export namespace ThumbnailCore {
export type Props = ThumbnailProps;
export type Props = ThumbnailProps & ThumbnailImageProps;
export type RootProps = ThumbnailProps;
export type ImageProps = ThumbnailImageProps;
export type State = ThumbnailState;
}
34 changes: 34 additions & 0 deletions packages/core/src/core/ui/thumbnail/tests/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,40 @@ describe('ThumbnailCore', () => {
});
});

describe('resolveCrossOrigin', () => {
it('inherits when unset', () => {
const core = new ThumbnailCore();

expect(core.resolveCrossOrigin(undefined, 'anonymous')).toBe('anonymous');
expect(core.resolveCrossOrigin(undefined, 'use-credentials')).toBe('use-credentials');
});

it('returns nothing when there is nothing to inherit', () => {
const core = new ThumbnailCore();

expect(core.resolveCrossOrigin(undefined, undefined)).toBeUndefined();
expect(core.resolveCrossOrigin(undefined, null)).toBeUndefined();
});

it('prefers an explicit value over the inherited one', () => {
const core = new ThumbnailCore();

expect(core.resolveCrossOrigin('anonymous', 'use-credentials')).toBe('anonymous');
});

it('opts out of inheritance for an explicit null', () => {
const core = new ThumbnailCore();

expect(core.resolveCrossOrigin(null, 'anonymous')).toBeUndefined();
});

it('passes an empty value through rather than opting out', () => {
const core = new ThumbnailCore();

expect(core.resolveCrossOrigin('', 'use-credentials')).toBe('');
});
});

describe('getState', () => {
it('returns loading state', () => {
const core = new ThumbnailCore();
Expand Down
51 changes: 51 additions & 0 deletions packages/core/src/dom/ui/tests/thumbnail.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,35 @@ describe('createThumbnail', () => {
handle.destroy();
});

it('moves event handling to a replacement image', () => {
const first = createMockImg();
const second = createMockImg();
const onStateChange = vi.fn();
let img = first;

const handle = createThumbnail(
createOptions({
getImg: () => img,
onStateChange,
})
);

handle.updateSrc('sprite.jpg');

img = second;
handle.connect();
onStateChange.mockClear();

first.dispatchEvent(new Event('error'));
expect(onStateChange).not.toHaveBeenCalled();

second.dispatchEvent(new Event('load'));
expect(handle.loading).toBe(false);
expect(onStateChange).toHaveBeenCalledOnce();

handle.destroy();
});

it('sets error on img error', () => {
const img = createMockImg();
const onStateChange = vi.fn();
Expand Down Expand Up @@ -472,6 +501,28 @@ describe('createThumbnail', () => {

handle.destroy();
});

it('stays quiet when a settled image is handed back after a ref swap', () => {
const img = createMockImg();
const onStateChange = vi.fn();

Object.defineProperty(img, 'complete', { value: true, configurable: true });

const handle = createThumbnail(createOptions({ getImg: () => img, onStateChange }));

handle.updateSrc('sprite.jpg');
handle.connect();
expect(onStateChange).toHaveBeenCalledOnce();

// React detaches and reattaches the same node when a callback ref changes identity.
handle.disconnectImg(img);
handle.connect();

expect(handle.loading).toBe(false);
expect(onStateChange).toHaveBeenCalledOnce();

handle.destroy();
});
});

describe('destroy', () => {
Expand Down
76 changes: 54 additions & 22 deletions packages/core/src/dom/ui/thumbnail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,21 +17,22 @@ export interface ThumbnailApi {
readConstraints(): ThumbnailConstraints;
updateSrc(url: string | undefined): void;
connect(): void;
disconnectImg(img: HTMLImageElement): void;
destroy(): void;
}

export function createThumbnail(options: CreateThumbnailOptions): ThumbnailApi {
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 boundImg: HTMLImageElement | null = null;
let checkedImg: HTMLImageElement | null = null;
let stopListeningToImg: AbortController | null = null;
let stopObservingResize: (() => void) | null = null;

// Sprite sheets that have already failed, so re-entering one does not restart the loading state.
Expand Down Expand Up @@ -67,20 +68,25 @@ export function createThumbnail(options: CreateThumbnailOptions): ThumbnailApi {
}

function bindImg(img: HTMLImageElement): void {
listen(img, 'load', onImgLoad, { signal });
listen(img, 'error', onImgError, { signal });
stopListeningToImg = new AbortController();

listen(img, 'load', onImgLoad, { signal: stopListeningToImg.signal });
listen(img, 'error', onImgError, { signal: stopListeningToImg.signal });
}

// --- Lazy binding ---

function ensureBindings(): void {
if (!imgBound) {
const img = getImg();
const img = getImg();
const imageChanged = img !== boundImg;

if (img) {
bindImg(img);
imgBound = true;
}
if (imageChanged) {
stopListeningToImg?.abort();
stopListeningToImg = null;
boundImg = img;
checkedImg = null;

if (img) bindImg(img);
}

if (!stopObservingResize) {
Expand Down Expand Up @@ -126,23 +132,48 @@ export function createThumbnail(options: CreateThumbnailOptions): ThumbnailApi {
// Handle the case where the img already loaded or errored before listeners
// were bound (e.g., cached image in React where mount happens before useEffect).
const img = getImg();
if (!img || img === checkedImg) return;

if (img?.complete && lastSrc) {
if (img.naturalWidth > 0) {
naturalWidth = img.naturalWidth;
naturalHeight = img.naturalHeight;
loading = false;
error = false;
} else {
markFailed();
}
checkedImg = img;

onStateChange();
if (!img.complete || !lastSrc) return;

const previous = { loading, error, naturalWidth, naturalHeight };

if (img.naturalWidth > 0) {
naturalWidth = img.naturalWidth;
naturalHeight = img.naturalHeight;
loading = false;
error = false;
} else {
markFailed();
}

// A renderer may hand the same settled image back after a ref swap. Announcing an
// unchanged state would schedule another render, whose ref swap lands right back here.
const changed =
previous.loading !== loading ||
previous.error !== error ||
previous.naturalWidth !== naturalWidth ||
previous.naturalHeight !== naturalHeight;

if (changed) onStateChange();
}

function disconnectImg(img: HTMLImageElement): void {
if (img !== boundImg) return;

stopListeningToImg?.abort();
stopListeningToImg = null;
boundImg = null;
checkedImg = null;
}

function destroy(): void {
abort.abort();
stopListeningToImg?.abort();
stopListeningToImg = null;
boundImg = null;
checkedImg = null;
stopObservingResize?.();
stopObservingResize = null;
}
Expand Down Expand Up @@ -170,6 +201,7 @@ export function createThumbnail(options: CreateThumbnailOptions): ThumbnailApi {

updateSrc,
connect,
disconnectImg,
destroy,
};
}
23 changes: 6 additions & 17 deletions packages/html/src/ui/thumbnail/thumbnail-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import type { ThumbnailApi } from '@videojs/core/dom';
import { applyElementProps, applyStateDataAttrs, createThumbnail, selectTextTrack } from '@videojs/core/dom';
import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
import type { MediaTextTrackState } from '@videojs/media';
import { isNull, isUndefined } from '@videojs/utils/predicate';

import { playerContext } from '../../player/context';
import { PlayerController } from '../../player/player-controller';
Expand All @@ -32,7 +31,7 @@ export class ThumbnailElement extends UIElement {
crossOrigin: { type: String, attribute: 'crossorigin' },
loading: { type: String },
fetchPriority: { type: String, attribute: 'fetchpriority' },
} satisfies PropertyDeclarationMap<keyof ThumbnailCore.Props>;
} satisfies PropertyDeclarationMap<Exclude<keyof ThumbnailCore.Props, 'thumbnails'>>;

time = 0;
crossOrigin: ThumbnailCore.Props['crossOrigin'];
Expand Down Expand Up @@ -119,7 +118,7 @@ export class ThumbnailElement extends UIElement {

// Sync img attributes from element properties.
applyElementProps(this.#img, {
crossorigin: this.#resolveCrossOrigin(textTrack),
crossorigin: this.#core.resolveCrossOrigin(this.crossOrigin, this.#inheritedCrossOrigin(textTrack)),
loading: this.loading,
fetchpriority: this.fetchPriority,
});
Expand Down Expand Up @@ -161,21 +160,11 @@ export class ThumbnailElement extends UIElement {

/**
* Leaving `crossOrigin` unset means "follow the media element", so thumbnails keep working on a CORS-enabled player
* without a skin having to thread an attribute through. `null` opts out and fetches the sprites no-CORS, which is
* also what removing the attribute produces. A bare `crossorigin` is passed straight through, since the CORS-settings
* attribute reads it as Anonymous.
*
* Only the `<track>` path inherits: `thumbnails` set directly may point at a host that has nothing to do with the
* media element.
* without a skin having to thread an attribute through. Only the `<track>` path inherits: `thumbnails` set directly
* may point at a host that has nothing to do with the media element.
*/
#resolveCrossOrigin(textTrack: MediaTextTrackState | undefined): string | undefined {
if (isNull(this.crossOrigin)) return undefined;

if (!isUndefined(this.crossOrigin)) return this.crossOrigin;

if (this.#externalThumbnails) return undefined;

return textTrack?.thumbnailTrackCrossOrigin ?? undefined;
#inheritedCrossOrigin(textTrack: MediaTextTrackState | undefined): ThumbnailCore.Props['crossOrigin'] {
return this.#externalThumbnails ? undefined : textTrack?.thumbnailTrackCrossOrigin;
}

#applyResize(result: ThumbnailResizeResult): void {
Expand Down
4 changes: 3 additions & 1 deletion packages/react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,9 @@ export { StatusAnnouncer, type StatusAnnouncerProps } from './ui/status-announce
export { StatusIndicator } from './ui/status-indicator';
export type { StatusIndicatorRootProps } from './ui/status-indicator/status-indicator-root';
export type { StatusIndicatorValueProps } from './ui/status-indicator/status-indicator-value';
export { Thumbnail, type ThumbnailProps } from './ui/thumbnail/thumbnail';
export { Thumbnail } from './ui/thumbnail';
export type { ThumbnailImageProps } from './ui/thumbnail/thumbnail-image';
export type { ThumbnailRootProps } from './ui/thumbnail/thumbnail-root';
export { Time } from './ui/time';
export { TimeSlider } from './ui/time-slider';
export type {
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/ui/slider/index.parts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@ export { SliderFill as Fill, type SliderFillProps as FillProps } from './slider-
export { SliderPreview as Preview, type SliderPreviewProps as PreviewProps } from './slider-preview';
export { SliderRoot as Root, type SliderRootProps as RootProps } from './slider-root';
export { SliderThumb as Thumb, type SliderThumbProps as ThumbProps } from './slider-thumb';
export { SliderThumbnail as Thumbnail, type SliderThumbnailProps as ThumbnailProps } from './slider-thumbnail';
export * as Thumbnail from './slider-thumbnail.parts';
export { SliderTrack as Track, type SliderTrackProps as TrackProps } from './slider-track';
export { SliderValue as Value, type SliderValueProps as ValueProps } from './slider-value';
22 changes: 22 additions & 0 deletions packages/react/src/ui/slider/slider-thumbnail-root.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { ThumbnailCore } from '@videojs/core';
import type { ForwardedRef } from 'react';
import { forwardRef } from 'react';

import { ThumbnailRoot, type ThumbnailRootProps } from '../thumbnail/thumbnail-root';
import { useSliderPointerValue } from './context';

export interface SliderThumbnailRootProps extends Omit<ThumbnailRootProps, 'time'> {}

export const SliderThumbnailRoot = forwardRef(function SliderThumbnailRoot(
componentProps: SliderThumbnailRootProps,
forwardedRef: ForwardedRef<HTMLDivElement>
) {
const pointerValue = useSliderPointerValue();

return <ThumbnailRoot ref={forwardedRef} {...componentProps} time={pointerValue} />;
});

export namespace SliderThumbnailRoot {
export type Props = SliderThumbnailRootProps;
export type State = ThumbnailCore.State;
}
2 changes: 2 additions & 0 deletions packages/react/src/ui/slider/slider-thumbnail.parts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { ThumbnailImage as Image, type ThumbnailImageProps as ImageProps } from '../thumbnail/thumbnail-image';
export { SliderThumbnailRoot as Root, type SliderThumbnailRootProps as RootProps } from './slider-thumbnail-root';
Loading
Loading