feat(html): add thumbnail element (#646)

This commit is contained in:
rahim
2026-02-27 17:02:15 -08:00
committed by GitHub
parent ed22730592
commit 8457b1241c
4 changed files with 195 additions and 18 deletions
+9
View File
@@ -0,0 +1,9 @@
import { ThumbnailElement } from '../../ui/thumbnail/thumbnail-element';
customElements.define(ThumbnailElement.tagName, ThumbnailElement);
declare global {
interface HTMLElementTagNameMap {
[ThumbnailElement.tagName]: ThumbnailElement;
}
}
+1
View File
@@ -25,6 +25,7 @@ export { PlayButtonElement } from './ui/play-button/play-button-element';
export { PlaybackRateButtonElement } from './ui/playback-rate-button/playback-rate-button-element';
export { PosterElement } from './ui/poster/poster-element';
export { SeekButtonElement } from './ui/seek-button/seek-button-element';
export { ThumbnailElement } from './ui/thumbnail/thumbnail-element';
export { TimeElement } from './ui/time/time-element';
export { TimeGroupElement } from './ui/time/time-group-element';
export { TimeSeparatorElement } from './ui/time/time-separator-element';
@@ -0,0 +1,177 @@
import {
type MediaTextTrackState,
mapCuesToThumbnails,
ThumbnailCore,
ThumbnailDataAttrs,
type ThumbnailImage,
type ThumbnailResizeResult,
} from '@videojs/core';
import type { ThumbnailHandle } from '@videojs/core/dom';
import { applyElementProps, applyStateDataAttrs, createThumbnail, selectTextTrack } from '@videojs/core/dom';
import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
import { playerContext } from '../../player/context';
import { PlayerController } from '../../player/player-controller';
import { MediaElement } from '../media-element';
const SHADOW_CSS = `\
:host {
display: inline-block;
overflow: hidden;
}
img {
display: block;
}`;
export class ThumbnailElement extends MediaElement {
static readonly tagName = 'media-thumbnail';
static override properties = {
time: { type: Number },
crossOrigin: { type: String, attribute: 'crossorigin' },
loading: { type: String },
fetchPriority: { type: String, attribute: 'fetchpriority' },
} satisfies PropertyDeclarationMap<keyof ThumbnailCore.Props>;
time = 0;
crossOrigin: ThumbnailCore.Props['crossOrigin'];
loading: ThumbnailCore.Props['loading'];
fetchPriority: ThumbnailCore.Props['fetchPriority'];
readonly #core = new ThumbnailCore();
readonly #img = document.createElement('img');
readonly #textTracks = new PlayerController(this, playerContext, selectTextTrack);
#thumbnails: ThumbnailImage[] = [];
#externalThumbnails: ThumbnailImage[] | undefined;
#lastTextTrack: MediaTextTrackState | undefined;
#handle: ThumbnailHandle | null = null;
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
const style = document.createElement('style');
style.textContent = SHADOW_CSS;
shadow.appendChild(style);
this.#img.alt = '';
this.#img.setAttribute('part', 'img');
this.#img.setAttribute('aria-hidden', 'true');
this.#img.setAttribute('decoding', 'async');
shadow.appendChild(this.#img);
}
/**
* Set thumbnail images directly, bypassing the automatic `<track>` detection.
* When set, this takes priority over the text track path.
*/
get thumbnails(): ThumbnailImage[] | undefined {
return this.#externalThumbnails;
}
set thumbnails(value: ThumbnailImage[] | undefined) {
this.#externalThumbnails = value;
this.requestUpdate();
}
override connectedCallback(): void {
super.connectedCallback();
this.#handle = createThumbnail({
getContainer: () => this,
getImg: () => this.#img,
onStateChange: () => this.requestUpdate(),
});
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.#handle?.destroy();
this.#handle = null;
}
protected override update(changed: PropertyValues): void {
super.update(changed);
// Resolve thumbnails: external prop takes priority over auto <track> path.
if (this.#externalThumbnails) {
this.#thumbnails = this.#externalThumbnails;
} else {
const textTrack = this.#textTracks.value;
if (textTrack !== this.#lastTextTrack) {
this.#lastTextTrack = textTrack;
this.#thumbnails =
textTrack && textTrack.thumbnailCues.length > 0
? mapCuesToThumbnails(textTrack.thumbnailCues, textTrack.thumbnailTrackSrc ?? undefined)
: [];
}
}
const thumbnail = this.#core.findActiveThumbnail(this.#thumbnails, this.time);
// Sync img attributes from element properties.
applyElementProps(this.#img, {
crossorigin: this.crossOrigin || undefined,
loading: this.loading,
fetchpriority: this.fetchPriority,
});
// Track src changes via the handle.
this.#handle?.updateSrc(thumbnail?.url);
if (!thumbnail) {
this.#img.removeAttribute('src');
this.#resetStyles();
const state = this.#core.getState(false, false, undefined);
applyElementProps(this, this.#core.getAttrs(state));
applyStateDataAttrs(this, state, ThumbnailDataAttrs);
return;
}
// Set the img src directly (imperative DOM).
if (this.#img.getAttribute('src') !== thumbnail.url) {
this.#img.src = thumbnail.url;
}
const handle = this.#handle;
const state = this.#core.getState(handle?.loading ?? false, handle?.error ?? false, thumbnail);
applyElementProps(this, this.#core.getAttrs(state));
applyStateDataAttrs(this, state, ThumbnailDataAttrs);
if (handle?.naturalWidth && handle.naturalHeight) {
const constraints = handle.readConstraints();
const result = this.#core.resize(thumbnail, handle.naturalWidth, handle.naturalHeight, constraints);
if (result) {
this.#applyResize(result);
}
}
}
#applyResize(result: ThumbnailResizeResult): void {
this.style.width = `${result.containerWidth}px`;
this.style.height = `${result.containerHeight}px`;
const imgStyle = this.#img.style;
imgStyle.width = `${result.imageWidth}px`;
imgStyle.height = `${result.imageHeight}px`;
imgStyle.maxWidth = 'none';
imgStyle.transform =
result.offsetX || result.offsetY ? `translate(-${result.offsetX}px, -${result.offsetY}px)` : '';
}
#resetStyles(): void {
this.style.width = '';
this.style.height = '';
const imgStyle = this.#img.style;
imgStyle.width = '';
imgStyle.height = '';
imgStyle.maxWidth = '';
imgStyle.transform = '';
}
}
+8 -18
View File
@@ -27,7 +27,12 @@ export class TimeElement extends MediaElement {
constructor() {
super();
this.#signSpan.setAttribute('aria-hidden', 'true');
this.#signSpan.hidden = true;
this.appendChild(this.#signSpan);
this.appendChild(this.#textNode);
}
override connectedCallback(): void {
@@ -52,24 +57,9 @@ export class TimeElement extends MediaElement {
const state = this.#core.getState(media);
if (state.negative) {
this.#signSpan.textContent = this.negativeSign;
this.#textNode.textContent = state.text;
// Append elements if not already in DOM
if (!this.#signSpan.parentNode) {
this.textContent = '';
this.appendChild(this.#signSpan);
this.appendChild(this.#textNode);
}
} else {
// Remove sign span if present, use direct text
if (this.#signSpan.parentNode) {
this.#signSpan.remove();
this.#textNode.remove();
}
this.textContent = state.text;
}
this.#signSpan.hidden = !state.negative;
this.#signSpan.textContent = state.negative ? this.negativeSign : '';
this.#textNode.textContent = state.text;
applyElementProps(this, this.#core.getAttrs(state));
applyStateDataAttrs(this, state, TimeDataAttrs);