mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(core): add seek button component (#526)
This commit is contained in:
@@ -142,8 +142,8 @@ Visual grouping container. No special behavior — pure layout.
|
||||
```html
|
||||
<media-controls-group>
|
||||
<media-play-button></media-play-button>
|
||||
<media-seek-backward-button></media-seek-backward-button>
|
||||
<media-seek-forward-button></media-seek-forward-button>
|
||||
<media-seek-button seconds="-10"></media-seek-button>
|
||||
<media-seek-button seconds="10"></media-seek-button>
|
||||
</media-controls-group>
|
||||
```
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<SeekButtonProps> = {
|
||||
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<void> {
|
||||
if (this.#props.disabled) return;
|
||||
await media.seek(media.currentTime + this.#props.seconds);
|
||||
}
|
||||
}
|
||||
|
||||
export namespace SeekButtonCore {
|
||||
export type Props = SeekButtonProps;
|
||||
export type State = SeekButtonState;
|
||||
}
|
||||
@@ -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<SeekButtonState>;
|
||||
@@ -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> = {}): MediaTimeState {
|
||||
return {
|
||||
currentTime: 0,
|
||||
duration: 300,
|
||||
seeking: false,
|
||||
seek: vi.fn(async (time: number) => time),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createState(overrides: Partial<SeekButtonState> = {}): 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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<keyof SeekButtonCore.Props>;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
* <SeekButton seconds={-10} />
|
||||
*
|
||||
* <SeekButton
|
||||
* seconds={30}
|
||||
* render={(props, state) => (
|
||||
* <button {...props}>
|
||||
* {state.direction === 'backward' ? <RewindIcon /> : <FastForwardIcon />}
|
||||
* </button>
|
||||
* )}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export const SeekButton = forwardRef(function SeekButton(
|
||||
componentProps: SeekButtonProps,
|
||||
forwardedRef: ForwardedRef<HTMLButtonElement>
|
||||
) {
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user