diff --git a/internal/design/ui/controls-design.md b/internal/design/ui/controls-design.md index 64ed15d8..9f3c34b3 100644 --- a/internal/design/ui/controls-design.md +++ b/internal/design/ui/controls-design.md @@ -142,8 +142,8 @@ Visual grouping container. No special behavior — pure layout. ```html - - + + ``` diff --git a/packages/core/src/core/index.ts b/packages/core/src/core/index.ts index 1fa0ff8b..ccea9b90 100644 --- a/packages/core/src/core/index.ts +++ b/packages/core/src/core/index.ts @@ -11,6 +11,8 @@ export * from './ui/play-button/play-button-core'; export * from './ui/play-button/play-button-data-attrs'; export * from './ui/poster/poster-core'; export * from './ui/poster/poster-data-attrs'; +export * from './ui/seek-button/seek-button-core'; +export * from './ui/seek-button/seek-button-data-attrs'; export * from './ui/time/time-core'; export * from './ui/time/time-data-attrs'; export * from './ui/types'; diff --git a/packages/core/src/core/ui/seek-button/seek-button-core.ts b/packages/core/src/core/ui/seek-button/seek-button-core.ts new file mode 100644 index 00000000..8ee122e2 --- /dev/null +++ b/packages/core/src/core/ui/seek-button/seek-button-core.ts @@ -0,0 +1,79 @@ +import { defaults } from '@videojs/utils/object'; +import { isFunction } from '@videojs/utils/predicate'; +import type { NonNullableObject } from '@videojs/utils/types'; + +import type { MediaTimeState } from '../../media/state'; + +export interface SeekButtonProps { + /** Seconds to seek. Positive = forward, negative = backward. Default `30`. */ + seconds?: number | undefined; + /** Custom label for the button. */ + label?: string | ((state: SeekButtonState) => string) | undefined; + /** Whether the button is disabled. */ + disabled?: boolean | undefined; +} + +export type SeekButtonDirection = 'forward' | 'backward'; + +export interface SeekButtonState { + /** Whether a seek is in progress. */ + seeking: boolean; + /** Whether the button seeks forward or backward. */ + direction: SeekButtonDirection; +} + +export class SeekButtonCore { + static readonly defaultProps: NonNullableObject = { + seconds: 30, + label: '', + disabled: false, + }; + + #props = { ...SeekButtonCore.defaultProps }; + + constructor(props?: SeekButtonProps) { + if (props) this.setProps(props); + } + + setProps(props: SeekButtonProps): void { + this.#props = defaults(props, SeekButtonCore.defaultProps); + } + + getLabel(state: SeekButtonState): string { + const { label } = this.#props; + + if (isFunction(label)) { + const customLabel = label(state); + if (customLabel) return customLabel; + } else if (label) { + return label; + } + + const abs = Math.abs(this.#props.seconds); + return state.direction === 'backward' ? `Seek backward ${abs} seconds` : `Seek forward ${abs} seconds`; + } + + getAttrs(state: SeekButtonState) { + return { + 'aria-label': this.getLabel(state), + 'aria-disabled': this.#props.disabled ? 'true' : undefined, + }; + } + + getState(media: MediaTimeState): SeekButtonState { + return { + seeking: media.seeking, + direction: this.#props.seconds < 0 ? 'backward' : 'forward', + }; + } + + async seek(media: MediaTimeState): Promise { + if (this.#props.disabled) return; + await media.seek(media.currentTime + this.#props.seconds); + } +} + +export namespace SeekButtonCore { + export type Props = SeekButtonProps; + export type State = SeekButtonState; +} diff --git a/packages/core/src/core/ui/seek-button/seek-button-data-attrs.ts b/packages/core/src/core/ui/seek-button/seek-button-data-attrs.ts new file mode 100644 index 00000000..8bb374e0 --- /dev/null +++ b/packages/core/src/core/ui/seek-button/seek-button-data-attrs.ts @@ -0,0 +1,9 @@ +import type { StateAttrMap } from '../types'; +import type { SeekButtonState } from './seek-button-core'; + +export const SeekButtonDataAttrs = { + /** Present when a seek is in progress. */ + seeking: 'data-seeking', + /** Indicates the seek direction: `"forward"` or `"backward"`. */ + direction: 'data-direction', +} as const satisfies StateAttrMap; diff --git a/packages/core/src/core/ui/seek-button/tests/seek-button-core.test.ts b/packages/core/src/core/ui/seek-button/tests/seek-button-core.test.ts new file mode 100644 index 00000000..5a7616eb --- /dev/null +++ b/packages/core/src/core/ui/seek-button/tests/seek-button-core.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { MediaTimeState } from '../../../media/state'; +import type { SeekButtonState } from '../seek-button-core'; +import { SeekButtonCore } from '../seek-button-core'; + +function createMediaState(overrides: Partial = {}): MediaTimeState { + return { + currentTime: 0, + duration: 300, + seeking: false, + seek: vi.fn(async (time: number) => time), + ...overrides, + }; +} + +function createState(overrides: Partial = {}): SeekButtonState { + return { + seeking: false, + direction: 'forward', + ...overrides, + }; +} + +describe('SeekButtonCore', () => { + describe('setProps', () => { + it('uses default props', () => { + const core = new SeekButtonCore(); + const state = core.getState(createMediaState()); + expect(state.direction).toBe('forward'); + }); + + it('accepts constructor props', () => { + const core = new SeekButtonCore({ seconds: -10 }); + const state = core.getState(createMediaState()); + expect(state.direction).toBe('backward'); + }); + + it('accepts disabled via constructor', () => { + const core = new SeekButtonCore({ disabled: true }); + const attrs = core.getAttrs(createState()); + expect(attrs['aria-disabled']).toBe('true'); + }); + }); + + describe('getState', () => { + it('projects seeking from media state', () => { + const core = new SeekButtonCore(); + const state = core.getState(createMediaState({ seeking: true })); + expect(state.seeking).toBe(true); + }); + + it('derives forward direction from positive seconds', () => { + const core = new SeekButtonCore({ seconds: 15 }); + const state = core.getState(createMediaState()); + expect(state.direction).toBe('forward'); + }); + + it('derives backward direction from negative seconds', () => { + const core = new SeekButtonCore({ seconds: -15 }); + const state = core.getState(createMediaState()); + expect(state.direction).toBe('backward'); + }); + + it('defaults to forward direction', () => { + const core = new SeekButtonCore(); + const state = core.getState(createMediaState()); + expect(state.direction).toBe('forward'); + }); + }); + + describe('getLabel', () => { + it('returns forward label for forward direction', () => { + const core = new SeekButtonCore({ seconds: 30 }); + expect(core.getLabel(createState({ direction: 'forward' }))).toBe('Seek forward 30 seconds'); + }); + + it('returns backward label for backward direction', () => { + const core = new SeekButtonCore({ seconds: -10 }); + expect(core.getLabel(createState({ direction: 'backward' }))).toBe('Seek backward 10 seconds'); + }); + + it('uses absolute value in backward label', () => { + const core = new SeekButtonCore({ seconds: -30 }); + const label = core.getLabel(createState({ direction: 'backward' })); + expect(label).toBe('Seek backward 30 seconds'); + expect(label).not.toContain('-'); + }); + + it('returns custom string label', () => { + const core = new SeekButtonCore({ label: 'Skip' }); + expect(core.getLabel(createState())).toBe('Skip'); + }); + + it('returns custom function label', () => { + const core = new SeekButtonCore({ + label: (state) => (state.direction === 'backward' ? 'Rewind' : 'Skip ahead'), + }); + expect(core.getLabel(createState({ direction: 'backward' }))).toBe('Rewind'); + expect(core.getLabel(createState({ direction: 'forward' }))).toBe('Skip ahead'); + }); + + it('falls back to default when function returns empty', () => { + const core = new SeekButtonCore({ seconds: 10, label: () => '' }); + expect(core.getLabel(createState({ direction: 'forward' }))).toBe('Seek forward 10 seconds'); + }); + }); + + describe('getAttrs', () => { + it('returns aria-label', () => { + const core = new SeekButtonCore({ seconds: 30 }); + const attrs = core.getAttrs(createState({ direction: 'forward' })); + expect(attrs['aria-label']).toBe('Seek forward 30 seconds'); + }); + + it('sets aria-disabled when disabled', () => { + const core = new SeekButtonCore({ disabled: true }); + const attrs = core.getAttrs(createState()); + expect(attrs['aria-disabled']).toBe('true'); + }); + + it('omits aria-disabled when not disabled', () => { + const core = new SeekButtonCore(); + const attrs = core.getAttrs(createState()); + expect(attrs['aria-disabled']).toBeUndefined(); + }); + }); + + describe('seek', () => { + it('seeks forward by seconds offset', async () => { + const core = new SeekButtonCore({ seconds: 30 }); + const media = createMediaState({ currentTime: 60 }); + await core.seek(media); + expect(media.seek).toHaveBeenCalledWith(90); + }); + + it('seeks backward by negative seconds offset', async () => { + const core = new SeekButtonCore({ seconds: -10 }); + const media = createMediaState({ currentTime: 60 }); + await core.seek(media); + expect(media.seek).toHaveBeenCalledWith(50); + }); + + it('does not clamp the target time', async () => { + const core = new SeekButtonCore({ seconds: -30 }); + const media = createMediaState({ currentTime: 10 }); + await core.seek(media); + // Clamping is the store's responsibility, not the button's. + expect(media.seek).toHaveBeenCalledWith(-20); + }); + + it('does nothing when disabled', async () => { + const core = new SeekButtonCore({ disabled: true, seconds: 30 }); + const media = createMediaState({ currentTime: 60 }); + await core.seek(media); + expect(media.seek).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/html/src/define/ui/seek-button.ts b/packages/html/src/define/ui/seek-button.ts new file mode 100644 index 00000000..f4f11fe4 --- /dev/null +++ b/packages/html/src/define/ui/seek-button.ts @@ -0,0 +1,9 @@ +import { SeekButtonElement } from '../../ui/seek-button/seek-button-element'; + +customElements.define(SeekButtonElement.tagName, SeekButtonElement); + +declare global { + interface HTMLElementTagNameMap { + [SeekButtonElement.tagName]: SeekButtonElement; + } +} diff --git a/packages/html/src/index.ts b/packages/html/src/index.ts index 33221e1e..bff75add 100644 --- a/packages/html/src/index.ts +++ b/packages/html/src/index.ts @@ -23,6 +23,7 @@ export { MuteButtonElement } from './ui/mute-button/mute-button-element'; export { PiPButtonElement } from './ui/pip-button/pip-button-element'; export { PlayButtonElement } from './ui/play-button/play-button-element'; export { PosterElement } from './ui/poster/poster-element'; +export { SeekButtonElement } from './ui/seek-button/seek-button-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/seek-button/seek-button-element.ts b/packages/html/src/ui/seek-button/seek-button-element.ts new file mode 100644 index 00000000..8dda1e2b --- /dev/null +++ b/packages/html/src/ui/seek-button/seek-button-element.ts @@ -0,0 +1,66 @@ +import { SeekButtonCore, SeekButtonDataAttrs } from '@videojs/core'; +import { applyElementProps, applyStateDataAttrs, createButton, logMissingFeature, selectTime } 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'; + +export class SeekButtonElement extends MediaElement { + static readonly tagName = 'media-seek-button'; + + static override properties = { + seconds: { type: Number }, + label: { type: String }, + disabled: { type: Boolean }, + } satisfies PropertyDeclarationMap; + + seconds = SeekButtonCore.defaultProps.seconds; + label = SeekButtonCore.defaultProps.label; + disabled = SeekButtonCore.defaultProps.disabled; + + readonly #core = new SeekButtonCore(); + readonly #state = new PlayerController(this, playerContext, selectTime); + + #disconnect: AbortController | null = null; + + override connectedCallback(): void { + super.connectedCallback(); + + this.#disconnect = new AbortController(); + + const buttonProps = createButton({ + onActivate: () => this.#core.seek(this.#state.value!), + isDisabled: () => this.disabled || !this.#state.value, + }); + + applyElementProps(this, buttonProps, this.#disconnect.signal); + + if (__DEV__ && !this.#state.value) { + logMissingFeature(SeekButtonElement.tagName, 'time'); + } + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + this.#disconnect?.abort(); + this.#disconnect = null; + } + + protected override willUpdate(changed: PropertyValues): void { + super.willUpdate(changed); + this.#core.setProps(this); + } + + protected override update(changed: PropertyValues): void { + super.update(changed); + + const media = this.#state.value; + + if (!media) return; + + const state = this.#core.getState(media); + applyElementProps(this, this.#core.getAttrs(state)); + applyStateDataAttrs(this, state, SeekButtonDataAttrs); + } +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 6a6d33af..cb7a5de0 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -39,6 +39,7 @@ export { MuteButton, type MuteButtonProps } from './ui/mute-button/mute-button'; export { PiPButton, type PiPButtonProps } from './ui/pip-button/pip-button'; export { PlayButton, type PlayButtonProps } from './ui/play-button/play-button'; export { Poster, type PosterProps } from './ui/poster/poster'; +export { SeekButton, type SeekButtonProps } from './ui/seek-button/seek-button'; export { Time } from './ui/time'; // Utilities diff --git a/packages/react/src/ui/seek-button/seek-button.tsx b/packages/react/src/ui/seek-button/seek-button.tsx new file mode 100644 index 00000000..a675d461 --- /dev/null +++ b/packages/react/src/ui/seek-button/seek-button.tsx @@ -0,0 +1,71 @@ +'use client'; + +import { SeekButtonCore, SeekButtonDataAttrs } from '@videojs/core'; +import { logMissingFeature, selectTime } from '@videojs/core/dom'; +import type { ForwardedRef } from 'react'; +import { forwardRef, useState } from 'react'; + +import { usePlayer } from '../../player/context'; +import type { UIComponentProps } from '../../utils/types'; +import { renderElement } from '../../utils/use-render'; +import { useButton } from '../hooks/use-button'; + +export interface SeekButtonProps extends UIComponentProps<'button', SeekButtonCore.State>, SeekButtonCore.Props {} + +/** + * A button that seeks forward or backward by a configurable number of seconds. + * + * @example + * ```tsx + * + * + * ( + * + * )} + * /> + * ``` + */ +export const SeekButton = forwardRef(function SeekButton( + componentProps: SeekButtonProps, + forwardedRef: ForwardedRef +) { + const { render, className, style, seconds, label, disabled, ...elementProps } = componentProps; + + const time = usePlayer(selectTime); + + const [core] = useState(() => new SeekButtonCore()); + core.setProps({ seconds, label, disabled }); + + const { getButtonProps, buttonRef } = useButton({ + displayName: 'SeekButton', + onActivate: () => core.seek(time!), + isDisabled: () => disabled || !time, + }); + + if (!time) { + if (__DEV__) logMissingFeature('SeekButton', 'time'); + return null; + } + + const state = core.getState(time); + + return renderElement( + 'button', + { render, className, style }, + { + state, + stateAttrMap: SeekButtonDataAttrs, + ref: [forwardedRef, buttonRef], + props: [core.getAttrs(state), elementProps, getButtonProps()], + } + ); +}); + +export namespace SeekButton { + export type Props = SeekButtonProps; + export type State = SeekButtonCore.State; +}