diff --git a/packages/html/src/define/ui/thumbnail.ts b/packages/html/src/define/ui/thumbnail.ts new file mode 100644 index 00000000..49476c67 --- /dev/null +++ b/packages/html/src/define/ui/thumbnail.ts @@ -0,0 +1,9 @@ +import { ThumbnailElement } from '../../ui/thumbnail/thumbnail-element'; + +customElements.define(ThumbnailElement.tagName, ThumbnailElement); + +declare global { + interface HTMLElementTagNameMap { + [ThumbnailElement.tagName]: ThumbnailElement; + } +} diff --git a/packages/html/src/index.ts b/packages/html/src/index.ts index 0f2d4412..a6d98d8c 100644 --- a/packages/html/src/index.ts +++ b/packages/html/src/index.ts @@ -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'; diff --git a/packages/html/src/ui/thumbnail/thumbnail-element.ts b/packages/html/src/ui/thumbnail/thumbnail-element.ts new file mode 100644 index 00000000..4b7b0dd2 --- /dev/null +++ b/packages/html/src/ui/thumbnail/thumbnail-element.ts @@ -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; + + 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 `` 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 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 = ''; + } +} diff --git a/packages/html/src/ui/time/time-element.ts b/packages/html/src/ui/time/time-element.ts index 5dbb6e63..76bc23c9 100644 --- a/packages/html/src/ui/time/time-element.ts +++ b/packages/html/src/ui/time/time-element.ts @@ -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);