mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(html): add thumbnail element (#646)
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
import { ThumbnailElement } from '../../ui/thumbnail/thumbnail-element';
|
||||
|
||||
customElements.define(ThumbnailElement.tagName, ThumbnailElement);
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[ThumbnailElement.tagName]: ThumbnailElement;
|
||||
}
|
||||
}
|
||||
@@ -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 = '';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user