diff --git a/packages/core/src/core/ui/controls/controls-core.ts b/packages/core/src/core/ui/controls/controls-core.ts index 9db679b2..3ab5402b 100644 --- a/packages/core/src/core/ui/controls/controls-core.ts +++ b/packages/core/src/core/ui/controls/controls-core.ts @@ -6,7 +6,14 @@ export interface ControlsState { } export class ControlsCore { - getState(media: MediaControlsState): ControlsState { + #media: MediaControlsState | null = null; + + setMedia(media: MediaControlsState): void { + this.#media = media; + } + + getState(): ControlsState { + const media = this.#media!; return { visible: media.controlsVisible, userActive: media.userActive, diff --git a/packages/core/src/core/ui/controls/tests/controls-core.test.ts b/packages/core/src/core/ui/controls/tests/controls-core.test.ts index 96bbea0e..238b74ad 100644 --- a/packages/core/src/core/ui/controls/tests/controls-core.test.ts +++ b/packages/core/src/core/ui/controls/tests/controls-core.test.ts @@ -9,43 +9,50 @@ describe('ControlsCore', () => { const core = new ControlsCore(); const media = createControlsState({ controlsVisible: true }); - expect(core.getState(media)).toEqual({ visible: true, userActive: true }); + core.setMedia(media); + expect(core.getState()).toEqual({ visible: true, userActive: true }); }); it('returns visible: false when controlsVisible is false', () => { const core = new ControlsCore(); const media = createControlsState({ controlsVisible: false }); - expect(core.getState(media)).toEqual({ visible: false, userActive: true }); + core.setMedia(media); + expect(core.getState()).toEqual({ visible: false, userActive: true }); }); it('returns userActive: false when userActive is false', () => { const core = new ControlsCore(); const media = createControlsState({ userActive: false }); - expect(core.getState(media)).toEqual({ visible: true, userActive: false }); + core.setMedia(media); + expect(core.getState()).toEqual({ visible: true, userActive: false }); }); it('projects visible and userActive independently', () => { const core = new ControlsCore(); // All four quadrants of the 2x2 boolean matrix - expect(core.getState(createControlsState({ controlsVisible: true, userActive: true }))).toEqual({ + core.setMedia(createControlsState({ controlsVisible: true, userActive: true })); + expect(core.getState()).toEqual({ visible: true, userActive: true, }); - expect(core.getState(createControlsState({ controlsVisible: true, userActive: false }))).toEqual({ + core.setMedia(createControlsState({ controlsVisible: true, userActive: false })); + expect(core.getState()).toEqual({ visible: true, userActive: false, }); - expect(core.getState(createControlsState({ controlsVisible: false, userActive: true }))).toEqual({ + core.setMedia(createControlsState({ controlsVisible: false, userActive: true })); + expect(core.getState()).toEqual({ visible: false, userActive: true, }); - expect(core.getState(createControlsState({ controlsVisible: false, userActive: false }))).toEqual({ + core.setMedia(createControlsState({ controlsVisible: false, userActive: false })); + expect(core.getState()).toEqual({ visible: false, userActive: false, }); @@ -53,7 +60,8 @@ describe('ControlsCore', () => { it('returns only primitive values', () => { const core = new ControlsCore(); - const state = core.getState(createControlsState()); + core.setMedia(createControlsState()); + const state = core.getState(); const functionKeys = Object.entries(state).filter(([, value]) => typeof value === 'function'); expect(functionKeys).toHaveLength(0); diff --git a/packages/core/src/core/ui/fullscreen-button/fullscreen-button-core.ts b/packages/core/src/core/ui/fullscreen-button/fullscreen-button-core.ts index 6e33ea23..9a2161ef 100644 --- a/packages/core/src/core/ui/fullscreen-button/fullscreen-button-core.ts +++ b/packages/core/src/core/ui/fullscreen-button/fullscreen-button-core.ts @@ -23,6 +23,7 @@ export class FullscreenButtonCore { }; #props = { ...FullscreenButtonCore.defaultProps }; + #media: MediaFullscreenState | null = null; constructor(props?: FullscreenButtonProps) { if (props) this.setProps(props); @@ -52,7 +53,12 @@ export class FullscreenButtonCore { }; } - getState(media: MediaFullscreenState): FullscreenButtonState { + setMedia(media: MediaFullscreenState): void { + this.#media = media; + } + + getState(): FullscreenButtonState { + const media = this.#media!; return { fullscreen: media.fullscreen, availability: media.fullscreenAvailability, diff --git a/packages/core/src/core/ui/fullscreen-button/tests/fullscreen-button-core.test.ts b/packages/core/src/core/ui/fullscreen-button/tests/fullscreen-button-core.test.ts index 6c961313..b9446ac1 100644 --- a/packages/core/src/core/ui/fullscreen-button/tests/fullscreen-button-core.test.ts +++ b/packages/core/src/core/ui/fullscreen-button/tests/fullscreen-button-core.test.ts @@ -27,7 +27,8 @@ describe('FullscreenButtonCore', () => { it('projects fullscreen and availability', () => { const core = new FullscreenButtonCore(); const media = createMediaState({ fullscreen: true }); - const state = core.getState(media); + core.setMedia(media); + const state = core.getState(); expect(state.fullscreen).toBe(true); expect(state.availability).toBe('available'); @@ -35,7 +36,8 @@ describe('FullscreenButtonCore', () => { it('reflects unsupported availability', () => { const core = new FullscreenButtonCore(); - const state = core.getState(createMediaState({ fullscreenAvailability: 'unsupported' })); + core.setMedia(createMediaState({ fullscreenAvailability: 'unsupported' })); + const state = core.getState(); expect(state.availability).toBe('unsupported'); }); diff --git a/packages/core/src/core/ui/mute-button/mute-button-core.ts b/packages/core/src/core/ui/mute-button/mute-button-core.ts index ac28c5a1..28c22d95 100644 --- a/packages/core/src/core/ui/mute-button/mute-button-core.ts +++ b/packages/core/src/core/ui/mute-button/mute-button-core.ts @@ -31,6 +31,7 @@ export class MuteButtonCore { }; #props = { ...MuteButtonCore.defaultProps }; + #media: MediaVolumeState | null = null; constructor(props?: MuteButtonProps) { if (props) this.setProps(props); @@ -60,7 +61,12 @@ export class MuteButtonCore { }; } - getState(media: MediaVolumeState): MuteButtonState { + setMedia(media: MediaVolumeState): void { + this.#media = media; + } + + getState(): MuteButtonState { + const media = this.#media!; return { muted: media.muted, volumeLevel: getVolumeLevel(media), diff --git a/packages/core/src/core/ui/mute-button/tests/mute-button-core.test.ts b/packages/core/src/core/ui/mute-button/tests/mute-button-core.test.ts index a631e5af..9b620ad2 100644 --- a/packages/core/src/core/ui/mute-button/tests/mute-button-core.test.ts +++ b/packages/core/src/core/ui/mute-button/tests/mute-button-core.test.ts @@ -28,7 +28,8 @@ describe('MuteButtonCore', () => { it('projects muted and volumeLevel', () => { const core = new MuteButtonCore(); const media = createMediaState({ muted: false, volume: 1 }); - const state = core.getState(media); + core.setMedia(media); + const state = core.getState(); expect(state.muted).toBe(false); expect(state.volumeLevel).toBe('high'); @@ -36,7 +37,8 @@ describe('MuteButtonCore', () => { it('returns off when muted', () => { const core = new MuteButtonCore(); - const state = core.getState(createMediaState({ muted: true, volume: 0.8 })); + core.setMedia(createMediaState({ muted: true, volume: 0.8 })); + const state = core.getState(); expect(state.muted).toBe(true); expect(state.volumeLevel).toBe('off'); @@ -44,26 +46,26 @@ describe('MuteButtonCore', () => { it('returns off when volume is 0', () => { const core = new MuteButtonCore(); - const state = core.getState(createMediaState({ volume: 0 })); - expect(state.volumeLevel).toBe('off'); + core.setMedia(createMediaState({ volume: 0 })); + expect(core.getState().volumeLevel).toBe('off'); }); it('returns low when volume < 0.5', () => { const core = new MuteButtonCore(); - const state = core.getState(createMediaState({ volume: 0.3 })); - expect(state.volumeLevel).toBe('low'); + core.setMedia(createMediaState({ volume: 0.3 })); + expect(core.getState().volumeLevel).toBe('low'); }); it('returns medium when volume < 0.75', () => { const core = new MuteButtonCore(); - const state = core.getState(createMediaState({ volume: 0.6 })); - expect(state.volumeLevel).toBe('medium'); + core.setMedia(createMediaState({ volume: 0.6 })); + expect(core.getState().volumeLevel).toBe('medium'); }); it('returns high when volume >= 0.75', () => { const core = new MuteButtonCore(); - const state = core.getState(createMediaState({ volume: 0.75 })); - expect(state.volumeLevel).toBe('high'); + core.setMedia(createMediaState({ volume: 0.75 })); + expect(core.getState().volumeLevel).toBe('high'); }); }); diff --git a/packages/core/src/core/ui/pip-button/pip-button-core.ts b/packages/core/src/core/ui/pip-button/pip-button-core.ts index dabc24b1..a448182f 100644 --- a/packages/core/src/core/ui/pip-button/pip-button-core.ts +++ b/packages/core/src/core/ui/pip-button/pip-button-core.ts @@ -23,6 +23,7 @@ export class PiPButtonCore { }; #props = { ...PiPButtonCore.defaultProps }; + #media: MediaPictureInPictureState | null = null; constructor(props?: PiPButtonProps) { if (props) this.setProps(props); @@ -52,7 +53,12 @@ export class PiPButtonCore { }; } - getState(media: MediaPictureInPictureState): PiPButtonState { + setMedia(media: MediaPictureInPictureState): void { + this.#media = media; + } + + getState(): PiPButtonState { + const media = this.#media!; return { pip: media.pip, availability: media.pipAvailability, diff --git a/packages/core/src/core/ui/pip-button/tests/pip-button-core.test.ts b/packages/core/src/core/ui/pip-button/tests/pip-button-core.test.ts index b51f9297..1982498e 100644 --- a/packages/core/src/core/ui/pip-button/tests/pip-button-core.test.ts +++ b/packages/core/src/core/ui/pip-button/tests/pip-button-core.test.ts @@ -27,7 +27,8 @@ describe('PiPButtonCore', () => { it('projects pip and availability', () => { const core = new PiPButtonCore(); const media = createMediaState({ pip: true }); - const state = core.getState(media); + core.setMedia(media); + const state = core.getState(); expect(state.pip).toBe(true); expect(state.availability).toBe('available'); @@ -35,7 +36,8 @@ describe('PiPButtonCore', () => { it('reflects unsupported availability', () => { const core = new PiPButtonCore(); - const state = core.getState(createMediaState({ pipAvailability: 'unsupported' })); + core.setMedia(createMediaState({ pipAvailability: 'unsupported' })); + const state = core.getState(); expect(state.availability).toBe('unsupported'); }); diff --git a/packages/core/src/core/ui/play-button/play-button-core.ts b/packages/core/src/core/ui/play-button/play-button-core.ts index 73e016d9..5dae0279 100644 --- a/packages/core/src/core/ui/play-button/play-button-core.ts +++ b/packages/core/src/core/ui/play-button/play-button-core.ts @@ -20,6 +20,7 @@ export class PlayButtonCore { }; #props = { ...PlayButtonCore.defaultProps }; + #media: MediaPlaybackState | null = null; constructor(props?: PlayButtonProps) { if (props) this.setProps(props); @@ -50,7 +51,12 @@ export class PlayButtonCore { }; } - getState(media: MediaPlaybackState): PlayButtonState { + setMedia(media: MediaPlaybackState): void { + this.#media = media; + } + + getState(): PlayButtonState { + const media = this.#media!; return { paused: media.paused, ended: media.ended, diff --git a/packages/core/src/core/ui/play-button/tests/play-button-core.test.ts b/packages/core/src/core/ui/play-button/tests/play-button-core.test.ts index b07b46e0..2efb0779 100644 --- a/packages/core/src/core/ui/play-button/tests/play-button-core.test.ts +++ b/packages/core/src/core/ui/play-button/tests/play-button-core.test.ts @@ -44,7 +44,8 @@ describe('PlayButtonCore', () => { it('projects data fields from media state', () => { const core = new PlayButtonCore(); const media = createMediaState({ paused: true, ended: false, started: true }); - const state = core.getState(media); + core.setMedia(media); + const state = core.getState(); expect(state.paused).toBe(true); expect(state.ended).toBe(false); @@ -53,7 +54,8 @@ describe('PlayButtonCore', () => { it('reflects playing state', () => { const core = new PlayButtonCore(); - const state = core.getState(createMediaState({ paused: false, started: true })); + core.setMedia(createMediaState({ paused: false, started: true })); + const state = core.getState(); expect(state.paused).toBe(false); expect(state.started).toBe(true); diff --git a/packages/core/src/core/ui/playback-rate-button/playback-rate-button-core.ts b/packages/core/src/core/ui/playback-rate-button/playback-rate-button-core.ts index 16b42cd9..4f166ff0 100644 --- a/packages/core/src/core/ui/playback-rate-button/playback-rate-button-core.ts +++ b/packages/core/src/core/ui/playback-rate-button/playback-rate-button-core.ts @@ -22,6 +22,7 @@ export class PlaybackRateButtonCore { }; #props = { ...PlaybackRateButtonCore.defaultProps }; + #media: MediaPlaybackRateState | null = null; constructor(props?: PlaybackRateButtonProps) { if (props) this.setProps(props); @@ -51,7 +52,12 @@ export class PlaybackRateButtonCore { }; } - getState(media: MediaPlaybackRateState): PlaybackRateButtonState { + setMedia(media: MediaPlaybackRateState): void { + this.#media = media; + } + + getState(): PlaybackRateButtonState { + const media = this.#media!; return { rate: media.playbackRate, }; diff --git a/packages/core/src/core/ui/playback-rate-button/tests/playback-rate-button-core.test.ts b/packages/core/src/core/ui/playback-rate-button/tests/playback-rate-button-core.test.ts index 16a1f32d..0c9d6a82 100644 --- a/packages/core/src/core/ui/playback-rate-button/tests/playback-rate-button-core.test.ts +++ b/packages/core/src/core/ui/playback-rate-button/tests/playback-rate-button-core.test.ts @@ -25,7 +25,8 @@ describe('PlaybackRateButtonCore', () => { it('projects playbackRate to rate', () => { const core = new PlaybackRateButtonCore(); const media = createMediaState({ playbackRate: 1.5 }); - const state = core.getState(media); + core.setMedia(media); + const state = core.getState(); expect(state.rate).toBe(1.5); }); diff --git a/packages/core/src/core/ui/popover/popover-core.ts b/packages/core/src/core/ui/popover/popover-core.ts index 9b28d301..0d711b6d 100644 --- a/packages/core/src/core/ui/popover/popover-core.ts +++ b/packages/core/src/core/ui/popover/popover-core.ts @@ -1,5 +1,6 @@ import { defaults } from '@videojs/utils/object'; import type { NonNullableObject } from '@videojs/utils/types'; + import type { TransitionFlags, TransitionState, TransitionStatus } from '../transition'; import { getTransitionFlags } from '../transition'; @@ -35,11 +36,11 @@ export interface PopoverProps { } /** - * The raw transition state managed by `createTransitionHandler`. Uses `active` + * The raw transition state managed by `createTransition`. Uses `active` * (not `open`) to distinguish the generic transition state machine from the * domain-specific `PopoverState.open`. */ -export interface PopoverInteraction extends TransitionState {} +export interface PopoverInput extends TransitionState {} export interface PopoverState extends TransitionFlags { open: boolean; @@ -73,14 +74,21 @@ export class PopoverCore { this.#props = defaults(props, PopoverCore.defaultProps); } - getState(interaction: PopoverInteraction): PopoverState { + #input: PopoverInput | null = null; + + setInput(input: PopoverInput): void { + this.#input = input; + } + + getState(): PopoverState { + const input = this.#input!; return { - open: interaction.active, - status: interaction.status, + open: input.active, + status: input.status, side: this.#props.side, align: this.#props.align, modal: this.#props.modal, - ...getTransitionFlags(interaction.status), + ...getTransitionFlags(input.status), }; } @@ -104,5 +112,5 @@ export class PopoverCore { export namespace PopoverCore { export type Props = PopoverProps; export type State = PopoverState; - export type Interaction = PopoverInteraction; + export type Input = PopoverInput; } diff --git a/packages/core/src/core/ui/popover/tests/popover-core.test.ts b/packages/core/src/core/ui/popover/tests/popover-core.test.ts index 67e51c18..78e17aed 100644 --- a/packages/core/src/core/ui/popover/tests/popover-core.test.ts +++ b/packages/core/src/core/ui/popover/tests/popover-core.test.ts @@ -1,32 +1,36 @@ import { describe, expect, it } from 'vitest'; -import { PopoverCore, type PopoverInteraction } from '../popover-core'; +import { PopoverCore, type PopoverInput } from '../popover-core'; -const CLOSED: PopoverInteraction = { active: false, status: 'idle' }; -const OPEN: PopoverInteraction = { active: true, status: 'idle' }; +const CLOSED: PopoverInput = { active: false, status: 'idle' }; +const OPEN: PopoverInput = { active: true, status: 'idle' }; describe('PopoverCore', () => { it('uses default props', () => { const core = new PopoverCore(); - const state = core.getState(CLOSED); + core.setInput(CLOSED); + const state = core.getState(); expect(state.side).toBe('top'); expect(state.align).toBe('center'); expect(state.modal).toBe(false); }); - it('merges interaction state', () => { + it('merges input state', () => { const core = new PopoverCore(); - const closed = core.getState(CLOSED); + core.setInput(CLOSED); + const closed = core.getState(); expect(closed.open).toBe(false); - const open = core.getState(OPEN); + core.setInput(OPEN); + const open = core.getState(); expect(open.open).toBe(true); }); it('applies custom props', () => { const core = new PopoverCore({ side: 'bottom', align: 'start' }); - const state = core.getState(OPEN); + core.setInput(OPEN); + const state = core.getState(); expect(state.side).toBe('bottom'); expect(state.align).toBe('start'); @@ -36,7 +40,8 @@ describe('PopoverCore', () => { const core = new PopoverCore(); core.setProps({ side: 'left', modal: true }); - const state = core.getState(OPEN); + core.setInput(OPEN); + const state = core.getState(); expect(state.side).toBe('left'); expect(state.modal).toBe(true); @@ -47,7 +52,8 @@ describe('PopoverCore', () => { describe('getTriggerAttrs', () => { it('returns aria-expanded false when closed', () => { const core = new PopoverCore(); - const state = core.getState(CLOSED); + core.setInput(CLOSED); + const state = core.getState(); const attrs = core.getTriggerAttrs(state); expect(attrs['aria-expanded']).toBe('false'); @@ -56,7 +62,8 @@ describe('PopoverCore', () => { it('returns aria-expanded true when open', () => { const core = new PopoverCore(); - const state = core.getState(OPEN); + core.setInput(OPEN); + const state = core.getState(); const attrs = core.getTriggerAttrs(state); expect(attrs['aria-expanded']).toBe('true'); @@ -64,7 +71,8 @@ describe('PopoverCore', () => { it('includes aria-controls when popupId is provided', () => { const core = new PopoverCore(); - const state = core.getState(OPEN); + core.setInput(OPEN); + const state = core.getState(); const attrs = core.getTriggerAttrs(state, 'popup-123'); expect(attrs['aria-controls']).toBe('popup-123'); @@ -72,7 +80,8 @@ describe('PopoverCore', () => { it('returns undefined aria-controls when popupId is not provided', () => { const core = new PopoverCore(); - const state = core.getState(OPEN); + core.setInput(OPEN); + const state = core.getState(); const attrs = core.getTriggerAttrs(state); expect(attrs['aria-controls']).toBeUndefined(); @@ -82,7 +91,8 @@ describe('PopoverCore', () => { describe('getPopupAttrs', () => { it('returns popover manual attribute', () => { const core = new PopoverCore(); - const state = core.getState(OPEN); + core.setInput(OPEN); + const state = core.getState(); const attrs = core.getPopupAttrs(state); expect(attrs.popover).toBe('manual'); @@ -90,7 +100,8 @@ describe('PopoverCore', () => { it('returns dialog role', () => { const core = new PopoverCore(); - const state = core.getState(OPEN); + core.setInput(OPEN); + const state = core.getState(); const attrs = core.getPopupAttrs(state); expect(attrs.role).toBe('dialog'); @@ -98,7 +109,8 @@ describe('PopoverCore', () => { it('sets aria-modal when modal is true', () => { const core = new PopoverCore({ modal: true }); - const state = core.getState(OPEN); + core.setInput(OPEN); + const state = core.getState(); const attrs = core.getPopupAttrs(state); expect(attrs['aria-modal']).toBe('true'); @@ -106,7 +118,8 @@ describe('PopoverCore', () => { it('omits aria-modal when not modal', () => { const core = new PopoverCore(); - const state = core.getState(OPEN); + core.setInput(OPEN); + const state = core.getState(); const attrs = core.getPopupAttrs(state); expect(attrs['aria-modal']).toBeUndefined(); @@ -114,7 +127,8 @@ describe('PopoverCore', () => { it('omits aria-modal when modal is trap-focus', () => { const core = new PopoverCore({ modal: 'trap-focus' }); - const state = core.getState(OPEN); + core.setInput(OPEN); + const state = core.getState(); const attrs = core.getPopupAttrs(state); expect(attrs['aria-modal']).toBeUndefined(); @@ -124,7 +138,8 @@ describe('PopoverCore', () => { describe('transition flags', () => { it('sets transitionStarting when status is starting', () => { const core = new PopoverCore(); - const state = core.getState({ active: true, status: 'starting' }); + core.setInput({ active: true, status: 'starting' }); + const state = core.getState(); expect(state.transitionStarting).toBe(true); expect(state.transitionEnding).toBe(false); @@ -132,7 +147,8 @@ describe('PopoverCore', () => { it('sets transitionEnding when status is ending', () => { const core = new PopoverCore(); - const state = core.getState({ active: true, status: 'ending' }); + core.setInput({ active: true, status: 'ending' }); + const state = core.getState(); expect(state.transitionStarting).toBe(false); expect(state.transitionEnding).toBe(true); @@ -140,7 +156,8 @@ describe('PopoverCore', () => { it('both false when status is idle', () => { const core = new PopoverCore(); - const state = core.getState(OPEN); + core.setInput(OPEN); + const state = core.getState(); expect(state.transitionStarting).toBe(false); expect(state.transitionEnding).toBe(false); diff --git a/packages/core/src/core/ui/poster/poster-core.ts b/packages/core/src/core/ui/poster/poster-core.ts index bfbc15ee..7d931ef5 100644 --- a/packages/core/src/core/ui/poster/poster-core.ts +++ b/packages/core/src/core/ui/poster/poster-core.ts @@ -5,7 +5,14 @@ export interface PosterState { } export class PosterCore { - getState(media: MediaPlaybackState): PosterState { + #media: MediaPlaybackState | null = null; + + setMedia(media: MediaPlaybackState): void { + this.#media = media; + } + + getState(): PosterState { + const media = this.#media!; return { visible: !media.started, }; diff --git a/packages/core/src/core/ui/poster/tests/poster-core.test.ts b/packages/core/src/core/ui/poster/tests/poster-core.test.ts index 24b596ef..b46d74da 100644 --- a/packages/core/src/core/ui/poster/tests/poster-core.test.ts +++ b/packages/core/src/core/ui/poster/tests/poster-core.test.ts @@ -21,7 +21,8 @@ describe('PosterCore', () => { const core = new PosterCore(); const media = createMediaState({ started: false }); - const state = core.getState(media); + core.setMedia(media); + const state = core.getState(); expect(state.visible).toBe(true); }); @@ -30,7 +31,8 @@ describe('PosterCore', () => { const core = new PosterCore(); const media = createMediaState({ started: true }); - const state = core.getState(media); + core.setMedia(media); + const state = core.getState(); expect(state.visible).toBe(false); }); @@ -39,7 +41,8 @@ describe('PosterCore', () => { const core = new PosterCore(); const media = createMediaState(); - const state = core.getState(media); + core.setMedia(media); + const state = core.getState(); expect(state).toEqual({ visible: true }); @@ -51,23 +54,28 @@ describe('PosterCore', () => { const core = new PosterCore(); // Started but paused - should not be visible - expect(core.getState(createMediaState({ started: true, paused: true })).visible).toBe(false); + core.setMedia(createMediaState({ started: true, paused: true })); + expect(core.getState().visible).toBe(false); // Started and playing - should not be visible - expect(core.getState(createMediaState({ started: true, paused: false })).visible).toBe(false); + core.setMedia(createMediaState({ started: true, paused: false })); + expect(core.getState().visible).toBe(false); // Not started and paused - should be visible - expect(core.getState(createMediaState({ started: false, paused: true })).visible).toBe(true); + core.setMedia(createMediaState({ started: false, paused: true })); + expect(core.getState().visible).toBe(true); }); it('visibility is independent of ended state', () => { const core = new PosterCore(); // Started and ended - should not be visible (started takes precedence) - expect(core.getState(createMediaState({ started: true, ended: true })).visible).toBe(false); + core.setMedia(createMediaState({ started: true, ended: true })); + expect(core.getState().visible).toBe(false); // Not started and ended (edge case) - should be visible - expect(core.getState(createMediaState({ started: false, ended: true })).visible).toBe(true); + core.setMedia(createMediaState({ started: false, ended: true })); + expect(core.getState().visible).toBe(true); }); }); }); 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 index 8ee122e2..7e8ec9d6 100644 --- a/packages/core/src/core/ui/seek-button/seek-button-core.ts +++ b/packages/core/src/core/ui/seek-button/seek-button-core.ts @@ -30,6 +30,7 @@ export class SeekButtonCore { }; #props = { ...SeekButtonCore.defaultProps }; + #media: MediaTimeState | null = null; constructor(props?: SeekButtonProps) { if (props) this.setProps(props); @@ -60,7 +61,12 @@ export class SeekButtonCore { }; } - getState(media: MediaTimeState): SeekButtonState { + setMedia(media: MediaTimeState): void { + this.#media = media; + } + + getState(): SeekButtonState { + const media = this.#media!; return { seeking: media.seeking, direction: this.#props.seconds < 0 ? 'backward' : 'forward', 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 index 5a7616eb..1829fd1a 100644 --- 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 @@ -26,13 +26,15 @@ describe('SeekButtonCore', () => { describe('setProps', () => { it('uses default props', () => { const core = new SeekButtonCore(); - const state = core.getState(createMediaState()); + core.setMedia(createMediaState()); + const state = core.getState(); expect(state.direction).toBe('forward'); }); it('accepts constructor props', () => { const core = new SeekButtonCore({ seconds: -10 }); - const state = core.getState(createMediaState()); + core.setMedia(createMediaState()); + const state = core.getState(); expect(state.direction).toBe('backward'); }); @@ -46,26 +48,26 @@ describe('SeekButtonCore', () => { describe('getState', () => { it('projects seeking from media state', () => { const core = new SeekButtonCore(); - const state = core.getState(createMediaState({ seeking: true })); - expect(state.seeking).toBe(true); + core.setMedia(createMediaState({ seeking: true })); + expect(core.getState().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'); + core.setMedia(createMediaState()); + expect(core.getState().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'); + core.setMedia(createMediaState()); + expect(core.getState().direction).toBe('backward'); }); it('defaults to forward direction', () => { const core = new SeekButtonCore(); - const state = core.getState(createMediaState()); - expect(state.direction).toBe('forward'); + core.setMedia(createMediaState()); + expect(core.getState().direction).toBe('forward'); }); }); diff --git a/packages/core/src/core/ui/slider/slider-core.ts b/packages/core/src/core/ui/slider/slider-core.ts index ef50c740..0dfdc629 100644 --- a/packages/core/src/core/ui/slider/slider-core.ts +++ b/packages/core/src/core/ui/slider/slider-core.ts @@ -3,8 +3,8 @@ import { defaults } from '@videojs/utils/object'; import { isFunction } from '@videojs/utils/predicate'; import type { NonNullableObject } from '@videojs/utils/types'; -/** Shared configuration for all slider variants. */ -export interface SliderBaseProps { +/** Configuration shared by all slider variants. */ +export interface SliderProps { /** Custom label for the slider. */ label?: string | ((state: SliderState) => string) | undefined; /** Step increment for value changes (arrow keys). */ @@ -17,9 +17,6 @@ export interface SliderBaseProps { disabled?: boolean | undefined; /** How the thumb aligns at the track edges. `edge` constrains the thumb within track bounds. */ thumbAlignment?: 'center' | 'edge' | undefined; -} - -export interface SliderProps extends SliderBaseProps { /** Current slider value. */ value?: number | undefined; /** Minimum value of the slider range. */ @@ -28,8 +25,8 @@ export interface SliderProps extends SliderBaseProps { max?: number | undefined; } -/** Current pointer/drag interaction state, typically provided by a DOM controller. */ -export interface SliderInteraction { +/** Current pointer/drag input state, typically provided by a DOM controller. */ +export interface SliderInput { /** Pointer position as a percentage of the track (0–100). */ pointerPercent: number; /** Drag position as a percentage of the track (0–100). */ @@ -63,25 +60,39 @@ export interface SliderState { thumbAlignment: 'center' | 'edge'; } +/** Base slider logic: value mapping, ARIA attrs, and step calculations. */ export class SliderCore { static readonly defaultProps: NonNullableObject = { label: '', - value: 0, - min: 0, - max: 100, step: 1, largeStep: 10, orientation: 'horizontal', disabled: false, thumbAlignment: 'center', + value: 0, + min: 0, + max: 100, + }; + + static readonly defaultInput: SliderInput = { + pointerPercent: 0, + dragPercent: 0, + dragging: false, + pointing: false, + focused: false, }; #props = { ...SliderCore.defaultProps }; + #input: SliderInput = { ...SliderCore.defaultInput }; get props(): Readonly> { return this.#props; } + get input(): Readonly { + return this.#input; + } + constructor(props?: SliderProps) { if (props) this.setProps(props); } @@ -90,16 +101,21 @@ export class SliderCore { this.#props = defaults(props, SliderCore.defaultProps); } - getState(interaction: SliderInteraction, value: number): SliderState { + setInput(input: SliderInput): void { + this.#input = input; + } + + getSliderState(value: number): SliderState { const { orientation, disabled, thumbAlignment } = this.#props; + const { pointerPercent, dragging, pointing, focused } = this.#input; return { value, fillPercent: this.percentFromValue(value), - pointerPercent: interaction.pointerPercent, - dragging: interaction.dragging, - pointing: interaction.pointing, - interactive: interaction.dragging || interaction.pointing || interaction.focused, + pointerPercent, + dragging, + pointing, + interactive: dragging || pointing || focused, orientation, disabled, thumbAlignment, @@ -145,6 +161,20 @@ export class SliderCore { return ((value - min) / (max - min)) * 100; } + /** Step as a percentage of the slider range. */ + getStepPercent(): number { + const { step, min, max } = this.#props; + const range = max - min; + return range > 0 ? (step / range) * 100 : 0; + } + + /** Large step as a percentage of the slider range. */ + getLargeStepPercent(): number { + const { largeStep, min, max } = this.#props; + const range = max - min; + return range > 0 ? (largeStep / range) * 100 : 0; + } + adjustPercentForAlignment(rawPercent: number, thumbSize: number, trackSize: number): number { if (this.#props.thumbAlignment === 'center' || trackSize === 0) { return rawPercent; @@ -160,5 +190,5 @@ export class SliderCore { export namespace SliderCore { export type Props = SliderProps; export type State = SliderState; - export type Interaction = SliderInteraction; + export type Input = SliderInput; } diff --git a/packages/core/src/core/ui/slider/tests/slider-core.test.ts b/packages/core/src/core/ui/slider/tests/slider-core.test.ts index 4ac91c60..e48a9696 100644 --- a/packages/core/src/core/ui/slider/tests/slider-core.test.ts +++ b/packages/core/src/core/ui/slider/tests/slider-core.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { SliderCore, type SliderInteraction } from '../slider-core'; +import { SliderCore, type SliderInput } from '../slider-core'; -function createInteraction(overrides: Partial = {}): SliderInteraction { +function createInput(overrides: Partial = {}): SliderInput { return { pointerPercent: 0, dragPercent: 0, @@ -30,10 +30,11 @@ describe('SliderCore', () => { }); }); - describe('getState', () => { + describe('getSliderState', () => { it('returns state with defaults', () => { const core = new SliderCore(); - const state = core.getState(createInteraction(), 50); + core.setInput(createInput()); + const state = core.getSliderState(50); expect(state.value).toBe(50); expect(state.fillPercent).toBe(50); @@ -48,13 +49,15 @@ describe('SliderCore', () => { it('computes fillPercent from value', () => { const core = new SliderCore({ min: 0, max: 200 }); - const state = core.getState(createInteraction(), 100); + core.setInput(createInput()); + const state = core.getSliderState(100); expect(state.fillPercent).toBe(50); }); - it('passes through interaction state', () => { + it('passes through input state', () => { const core = new SliderCore(); - const state = core.getState(createInteraction({ dragging: true, pointing: true, pointerPercent: 75 }), 30); + core.setInput(createInput({ dragging: true, pointing: true, pointerPercent: 75 })); + const state = core.getSliderState(30); expect(state.dragging).toBe(true); expect(state.pointing).toBe(true); @@ -64,25 +67,29 @@ describe('SliderCore', () => { it('interactive is true when only dragging', () => { const core = new SliderCore(); - const state = core.getState(createInteraction({ dragging: true }), 0); + core.setInput(createInput({ dragging: true })); + const state = core.getSliderState(0); expect(state.interactive).toBe(true); }); it('interactive is true when only pointing', () => { const core = new SliderCore(); - const state = core.getState(createInteraction({ pointing: true }), 0); + core.setInput(createInput({ pointing: true })); + const state = core.getSliderState(0); expect(state.interactive).toBe(true); }); it('interactive is true when only focused', () => { const core = new SliderCore(); - const state = core.getState(createInteraction({ focused: true }), 0); + core.setInput(createInput({ focused: true })); + const state = core.getSliderState(0); expect(state.interactive).toBe(true); }); it('uses custom orientation and disabled', () => { const core = new SliderCore({ orientation: 'vertical', disabled: true }); - const state = core.getState(createInteraction(), 0); + core.setInput(createInput()); + const state = core.getSliderState(0); expect(state.orientation).toBe('vertical'); expect(state.disabled).toBe(true); @@ -92,25 +99,32 @@ describe('SliderCore', () => { describe('getLabel', () => { it('returns empty string by default', () => { const core = new SliderCore(); - const state = core.getState(createInteraction(), 50); + core.setInput(createInput()); + const state = core.getSliderState(50); expect(core.getLabel(state)).toBe(''); }); it('returns custom string label', () => { const core = new SliderCore({ label: 'Brightness' }); - const state = core.getState(createInteraction(), 50); + core.setInput(createInput()); + const state = core.getSliderState(50); expect(core.getLabel(state)).toBe('Brightness'); }); it('calls function label with state', () => { const core = new SliderCore({ label: (state) => (state.dragging ? 'Dragging' : 'Idle') }); - expect(core.getLabel(core.getState(createInteraction({ dragging: true }), 0))).toBe('Dragging'); - expect(core.getLabel(core.getState(createInteraction(), 0))).toBe('Idle'); + + core.setInput(createInput({ dragging: true })); + expect(core.getLabel(core.getSliderState(0))).toBe('Dragging'); + + core.setInput(createInput()); + expect(core.getLabel(core.getSliderState(0))).toBe('Idle'); }); it('falls through when function returns empty string', () => { const core = new SliderCore({ label: () => '' }); - const state = core.getState(createInteraction(), 0); + core.setInput(createInput()); + const state = core.getSliderState(0); expect(core.getLabel(state)).toBe(''); }); }); @@ -118,7 +132,8 @@ describe('SliderCore', () => { describe('getAttrs', () => { it('returns aria attributes', () => { const core = new SliderCore(); - const state = core.getState(createInteraction(), 50); + core.setInput(createInput()); + const state = core.getSliderState(50); const attrs = core.getAttrs(state); expect(attrs.role).toBe('slider'); @@ -134,7 +149,8 @@ describe('SliderCore', () => { it('sets tabindex -1 and aria-disabled when disabled', () => { const core = new SliderCore({ disabled: true }); - const state = core.getState(createInteraction(), 0); + core.setInput(createInput()); + const state = core.getSliderState(0); const attrs = core.getAttrs(state); expect(attrs.tabindex).toBe(-1); @@ -143,7 +159,8 @@ describe('SliderCore', () => { it('uses custom min and max', () => { const core = new SliderCore({ min: 10, max: 50 }); - const state = core.getState(createInteraction(), 30); + core.setInput(createInput()); + const state = core.getSliderState(30); const attrs = core.getAttrs(state); expect(attrs['aria-valuemin']).toBe(10); @@ -239,12 +256,47 @@ describe('SliderCore', () => { }); }); + describe('getStepPercent', () => { + it('returns step as a percentage of the range', () => { + const core = new SliderCore({ step: 1, min: 0, max: 100 }); + expect(core.getStepPercent()).toBe(1); + }); + + it('handles custom ranges', () => { + const core = new SliderCore({ step: 5, min: 0, max: 50 }); + expect(core.getStepPercent()).toBe(10); + }); + + it('returns 0 when range is 0', () => { + const core = new SliderCore({ step: 1, min: 50, max: 50 }); + expect(core.getStepPercent()).toBe(0); + }); + }); + + describe('getLargeStepPercent', () => { + it('returns large step as a percentage of the range', () => { + const core = new SliderCore({ largeStep: 10, min: 0, max: 100 }); + expect(core.getLargeStepPercent()).toBe(10); + }); + + it('handles custom ranges', () => { + const core = new SliderCore({ largeStep: 25, min: 0, max: 50 }); + expect(core.getLargeStepPercent()).toBe(50); + }); + + it('returns 0 when range is 0', () => { + const core = new SliderCore({ largeStep: 10, min: 50, max: 50 }); + expect(core.getLargeStepPercent()).toBe(0); + }); + }); + describe('setProps', () => { it('updates props after construction', () => { const core = new SliderCore(); core.setProps({ min: 10, max: 50 }); - const state = core.getState(createInteraction(), 30); + core.setInput(createInput()); + const state = core.getSliderState(30); const attrs = core.getAttrs(state); expect(attrs['aria-valuemin']).toBe(10); diff --git a/packages/core/src/core/ui/time-slider/tests/time-slider-core.test.ts b/packages/core/src/core/ui/time-slider/tests/time-slider-core.test.ts index af22ea71..22fcdee4 100644 --- a/packages/core/src/core/ui/time-slider/tests/time-slider-core.test.ts +++ b/packages/core/src/core/ui/time-slider/tests/time-slider-core.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it, vi } from 'vitest'; import type { MediaBufferState, MediaTimeState } from '../../../media/state'; -import type { SliderInteraction } from '../../slider/slider-core'; +import type { SliderInput } from '../../slider/slider-core'; import { TimeSliderCore } from '../time-slider-core'; type TimeSliderMedia = MediaTimeState & MediaBufferState; -function createInteraction(overrides: Partial = {}): SliderInteraction { +function createInput(overrides: Partial = {}): SliderInput { return { pointerPercent: 0, dragPercent: 0, @@ -33,21 +33,26 @@ describe('TimeSliderCore', () => { describe('defaultProps', () => { it('has expected defaults', () => { expect(TimeSliderCore.defaultProps).toEqual({ + label: 'Seek', step: 1, largeStep: 10, orientation: 'horizontal', disabled: false, thumbAlignment: 'center', - label: 'Seek', + value: 0, + min: 0, + max: 100, commitThrottle: 100, }); }); }); - describe('getTimeState', () => { + describe('getState', () => { it('uses currentTime as value when not dragging', () => { const core = new TimeSliderCore(); - const state = core.getTimeState(createMediaState({ currentTime: 90, duration: 300 }), createInteraction()); + core.setInput(createInput()); + core.setMedia(createMediaState({ currentTime: 90, duration: 300 })); + const state = core.getState(); expect(state.value).toBe(90); expect(state.currentTime).toBe(90); @@ -57,10 +62,9 @@ describe('TimeSliderCore', () => { it('uses drag percent for value when dragging', () => { const core = new TimeSliderCore(); - const state = core.getTimeState( - createMediaState({ currentTime: 90, duration: 300 }), - createInteraction({ dragging: true, dragPercent: 50 }) - ); + core.setInput(createInput({ dragging: true, dragPercent: 50 })); + core.setMedia(createMediaState({ currentTime: 90, duration: 300 })); + const state = core.getState(); expect(state.value).toBe(150); // 50% of 300 expect(state.dragging).toBe(true); @@ -69,10 +73,9 @@ describe('TimeSliderCore', () => { it('uses raw precision during drag for smooth scrubbing', () => { const core = new TimeSliderCore(); - const state = core.getTimeState( - createMediaState({ currentTime: 0, duration: 10 }), - createInteraction({ dragging: true, dragPercent: 33.333 }) - ); + core.setInput(createInput({ dragging: true, dragPercent: 33.333 })); + core.setMedia(createMediaState({ currentTime: 0, duration: 10 })); + const state = core.getState(); // 33.333% of 10 = 3.3333, NOT snapped to step (which would be 3) expect(state.value).toBeCloseTo(3.3333, 3); @@ -80,51 +83,62 @@ describe('TimeSliderCore', () => { it('computes buffer percent from buffered ranges', () => { const core = new TimeSliderCore(); - const state = core.getTimeState(createMediaState({ duration: 200, buffered: [[0, 100]] }), createInteraction()); + core.setInput(createInput()); + core.setMedia(createMediaState({ duration: 200, buffered: [[0, 100]] })); + const state = core.getState(); expect(state.bufferPercent).toBe(50); // 100/200 * 100 }); it('uses end of the furthest buffered range', () => { const core = new TimeSliderCore(); - const state = core.getTimeState( + core.setInput(createInput()); + core.setMedia( createMediaState({ duration: 200, buffered: [ [0, 50], [60, 150], ], - }), - createInteraction() + }) ); + const state = core.getState(); expect(state.bufferPercent).toBe(75); // 150/200 * 100 }); it('returns 0 buffer percent when no buffered ranges', () => { const core = new TimeSliderCore(); - const state = core.getTimeState(createMediaState({ duration: 200, buffered: [] }), createInteraction()); + core.setInput(createInput()); + core.setMedia(createMediaState({ duration: 200, buffered: [] })); + const state = core.getState(); expect(state.bufferPercent).toBe(0); }); it('returns 0 buffer percent when duration is 0', () => { const core = new TimeSliderCore(); - const state = core.getTimeState(createMediaState({ duration: 0, buffered: [] }), createInteraction()); + core.setInput(createInput()); + core.setMedia(createMediaState({ duration: 0, buffered: [] })); + const state = core.getState(); expect(state.bufferPercent).toBe(0); }); it('passes through seeking state', () => { const core = new TimeSliderCore(); - const state = core.getTimeState(createMediaState({ seeking: true }), createInteraction()); + core.setInput(createInput()); + core.setMedia(createMediaState({ seeking: true })); + const state = core.getState(); expect(state.seeking).toBe(true); }); it('sets min to 0 and max to duration', () => { const core = new TimeSliderCore(); - const state = core.getTimeState(createMediaState({ duration: 600 }), createInteraction()); + core.setInput(createInput()); + core.setMedia(createMediaState({ duration: 600 })); + const state = core.getState(); const attrs = core.getAttrs(state); expect(attrs['aria-valuemin']).toBe(0); @@ -133,7 +147,9 @@ describe('TimeSliderCore', () => { it('handles zero duration', () => { const core = new TimeSliderCore(); - const state = core.getTimeState(createMediaState({ currentTime: 0, duration: 0 }), createInteraction()); + core.setInput(createInput()); + core.setMedia(createMediaState({ currentTime: 0, duration: 0 })); + const state = core.getState(); expect(state.fillPercent).toBe(0); expect(state.value).toBe(0); @@ -143,7 +159,9 @@ describe('TimeSliderCore', () => { describe('getAttrs', () => { it('returns aria-label and aria-valuetext', () => { const core = new TimeSliderCore(); - const state = core.getTimeState(createMediaState({ currentTime: 90, duration: 300 }), createInteraction()); + core.setInput(createInput()); + core.setMedia(createMediaState({ currentTime: 90, duration: 300 })); + const state = core.getState(); const attrs = core.getAttrs(state); expect(attrs['aria-label']).toBe('Seek'); @@ -153,7 +171,9 @@ describe('TimeSliderCore', () => { it('uses custom label', () => { const core = new TimeSliderCore({ label: 'Scrub' }); - const state = core.getTimeState(createMediaState({ currentTime: 0, duration: 300 }), createInteraction()); + core.setInput(createInput()); + core.setMedia(createMediaState({ currentTime: 0, duration: 300 })); + const state = core.getState(); const attrs = core.getAttrs(state); expect(attrs['aria-label']).toBe('Scrub'); @@ -161,7 +181,9 @@ describe('TimeSliderCore', () => { it('shows both times when duration is 0', () => { const core = new TimeSliderCore(); - const state = core.getTimeState(createMediaState({ currentTime: 0, duration: 0 }), createInteraction()); + core.setInput(createInput()); + core.setMedia(createMediaState({ currentTime: 0, duration: 0 })); + const state = core.getState(); const attrs = core.getAttrs(state); expect(attrs['aria-valuetext']).toBe('0 seconds of 0 seconds'); @@ -169,10 +191,9 @@ describe('TimeSliderCore', () => { it('includes dragged value in valuetext', () => { const core = new TimeSliderCore(); - const state = core.getTimeState( - createMediaState({ currentTime: 0, duration: 300 }), - createInteraction({ dragging: true, dragPercent: 50 }) - ); + core.setInput(createInput({ dragging: true, dragPercent: 50 })); + core.setMedia(createMediaState({ currentTime: 0, duration: 300 })); + const state = core.getState(); const attrs = core.getAttrs(state); // value is 150 (50% of 300) → "2 minutes, 30 seconds of 5 minutes" @@ -185,17 +206,21 @@ describe('TimeSliderCore', () => { const core = new TimeSliderCore(); core.setProps({ label: 'Progress' }); - const state = core.getTimeState(createMediaState({ currentTime: 0, duration: 100 }), createInteraction()); + core.setInput(createInput()); + core.setMedia(createMediaState({ currentTime: 0, duration: 100 })); + const state = core.getState(); const attrs = core.getAttrs(state); expect(attrs['aria-label']).toBe('Progress'); }); - it('preserves disabled across getTimeState calls', () => { + it('preserves disabled across getState calls', () => { const core = new TimeSliderCore({ disabled: true }); - // getTimeState overrides min/max via super.setProps — disabled must survive. - const state = core.getTimeState(createMediaState({ duration: 300 }), createInteraction()); + // getState overrides min/max via super.setProps — disabled must survive. + core.setInput(createInput()); + core.setMedia(createMediaState({ duration: 300 })); + const state = core.getState(); expect(state.disabled).toBe(true); @@ -204,18 +229,22 @@ describe('TimeSliderCore', () => { expect(attrs.tabindex).toBe(-1); }); - it('preserves thumbAlignment across getTimeState calls', () => { + it('preserves thumbAlignment across getState calls', () => { const core = new TimeSliderCore({ thumbAlignment: 'edge' }); - const state = core.getTimeState(createMediaState({ duration: 300 }), createInteraction()); + core.setInput(createInput()); + core.setMedia(createMediaState({ duration: 300 })); + const state = core.getState(); expect(state.thumbAlignment).toBe('edge'); }); - it('preserves orientation across getTimeState calls', () => { + it('preserves orientation across getState calls', () => { const core = new TimeSliderCore({ orientation: 'vertical' }); - const state = core.getTimeState(createMediaState({ duration: 300 }), createInteraction()); + core.setInput(createInput()); + core.setMedia(createMediaState({ duration: 300 })); + const state = core.getState(); expect(state.orientation).toBe('vertical'); }); diff --git a/packages/core/src/core/ui/time-slider/time-slider-core.ts b/packages/core/src/core/ui/time-slider/time-slider-core.ts index 303a8d84..a0be9ab2 100644 --- a/packages/core/src/core/ui/time-slider/time-slider-core.ts +++ b/packages/core/src/core/ui/time-slider/time-slider-core.ts @@ -4,9 +4,15 @@ import { formatTimeAsPhrase } from '@videojs/utils/time'; import type { NonNullableObject } from '@videojs/utils/types'; import type { MediaBufferState, MediaTimeState } from '../../media/state'; -import { type SliderBaseProps, SliderCore, type SliderInteraction, type SliderState } from '../slider/slider-core'; +import { SliderCore, type SliderProps, type SliderState } from '../slider/slider-core'; -export interface TimeSliderProps extends SliderBaseProps { +export interface TimeSliderProps extends SliderProps { + /** @internal Derived from `currentTime` — not user-settable. */ + value?: number | undefined; + /** @internal Always 0 — not user-settable. */ + min?: number | undefined; + /** @internal Derived from `duration` — not user-settable. */ + max?: number | undefined; /** Trailing-edge throttle (ms) for seek requests during drag. */ commitThrottle?: number | undefined; } @@ -16,19 +22,16 @@ export interface TimeSliderState extends SliderState, Pick = { - step: SliderCore.defaultProps.step, - largeStep: SliderCore.defaultProps.largeStep, - orientation: SliderCore.defaultProps.orientation, - disabled: SliderCore.defaultProps.disabled, - thumbAlignment: SliderCore.defaultProps.thumbAlignment, + static override readonly defaultProps: NonNullableObject = { + ...SliderCore.defaultProps, label: 'Seek', commitThrottle: 100, }; #props = { ...TimeSliderCore.defaultProps }; + #media: (MediaTimeState & MediaBufferState) | null = null; constructor(props?: TimeSliderProps) { super(); @@ -40,15 +43,21 @@ export class TimeSliderCore extends SliderCore { super.setProps({ ...props, min: 0 }); } - getTimeState(media: MediaTimeState & MediaBufferState, interaction: SliderInteraction): TimeSliderState { + setMedia(media: MediaTimeState & MediaBufferState): void { + this.#media = media; + } + + getState(): TimeSliderState { + const media = this.#media!; const { duration, currentTime, seeking, buffered } = media; + const { dragging, dragPercent } = this.input; // Override min/max for time domain, forwarding all user props so disabled/thumbAlignment aren't lost. super.setProps({ ...this.#props, min: 0, max: duration }); // Raw precision during drag for smooth scrubbing — step snapping only applies to keyboard. - const value = interaction.dragging ? clamp((interaction.dragPercent / 100) * duration, 0, duration) : currentTime; - const base = super.getState(interaction, value); + const value = dragging ? clamp((dragPercent / 100) * duration, 0, duration) : currentTime; + const base = super.getSliderState(value); // Use end of the furthest buffered range const bufferedEnd = buffered.length > 0 ? buffered[buffered.length - 1]![1] : 0; diff --git a/packages/core/src/core/ui/time/tests/time-core.test.ts b/packages/core/src/core/ui/time/tests/time-core.test.ts index 889fd540..7122ede5 100644 --- a/packages/core/src/core/ui/time/tests/time-core.test.ts +++ b/packages/core/src/core/ui/time/tests/time-core.test.ts @@ -17,13 +17,15 @@ describe('TimeCore', () => { describe('setProps', () => { it('uses default props', () => { const core = new TimeCore(); - const state = core.getState(createMediaState()); + core.setMedia(createMediaState()); + const state = core.getState(); expect(state.type).toBe('current'); }); it('accepts custom props', () => { const core = new TimeCore({ type: 'duration' }); - const state = core.getState(createMediaState()); + core.setMedia(createMediaState()); + const state = core.getState(); expect(state.type).toBe('duration'); }); }); @@ -31,7 +33,8 @@ describe('TimeCore', () => { describe('getState', () => { it('returns current time state', () => { const core = new TimeCore({ type: 'current' }); - const state = core.getState(createMediaState({ currentTime: 90 })); + core.setMedia(createMediaState({ currentTime: 90 })); + const state = core.getState(); expect(state.type).toBe('current'); expect(state.seconds).toBe(90); @@ -43,7 +46,8 @@ describe('TimeCore', () => { it('returns duration state', () => { const core = new TimeCore({ type: 'duration' }); - const state = core.getState(createMediaState({ duration: 300 })); + core.setMedia(createMediaState({ duration: 300 })); + const state = core.getState(); expect(state.type).toBe('duration'); expect(state.seconds).toBe(300); @@ -55,7 +59,8 @@ describe('TimeCore', () => { it('returns remaining time state', () => { const core = new TimeCore({ type: 'remaining' }); - const state = core.getState(createMediaState({ currentTime: 90, duration: 300 })); + core.setMedia(createMediaState({ currentTime: 90, duration: 300 })); + const state = core.getState(); expect(state.type).toBe('remaining'); expect(state.seconds).toBe(-210); // 90 - 300 @@ -67,7 +72,8 @@ describe('TimeCore', () => { it('returns unsigned text regardless of negativeSign prop', () => { const core = new TimeCore({ type: 'remaining', negativeSign: '−' }); - const state = core.getState(createMediaState({ currentTime: 90, duration: 300 })); + core.setMedia(createMediaState({ currentTime: 90, duration: 300 })); + const state = core.getState(); expect(state.negative).toBe(true); expect(state.text).toBe('3:30'); @@ -75,7 +81,8 @@ describe('TimeCore', () => { it('is not negative when remaining time is zero', () => { const core = new TimeCore({ type: 'remaining' }); - const state = core.getState(createMediaState({ currentTime: 300, duration: 300 })); + core.setMedia(createMediaState({ currentTime: 300, duration: 300 })); + const state = core.getState(); expect(state.seconds).toBe(0); expect(state.negative).toBe(false); @@ -84,7 +91,8 @@ describe('TimeCore', () => { it('shows hours when duration has hours', () => { const core = new TimeCore({ type: 'current' }); - const state = core.getState(createMediaState({ currentTime: 90, duration: 3700 })); + core.setMedia(createMediaState({ currentTime: 90, duration: 3700 })); + const state = core.getState(); expect(state.text).toBe('0:01:30'); }); @@ -93,25 +101,29 @@ describe('TimeCore', () => { describe('getLabel', () => { it('returns default label for current', () => { const core = new TimeCore({ type: 'current' }); - const state = core.getState(createMediaState()); + core.setMedia(createMediaState()); + const state = core.getState(); expect(core.getLabel(state)).toBe('Current time'); }); it('returns default label for duration', () => { const core = new TimeCore({ type: 'duration' }); - const state = core.getState(createMediaState()); + core.setMedia(createMediaState()); + const state = core.getState(); expect(core.getLabel(state)).toBe('Duration'); }); it('returns default label for remaining', () => { const core = new TimeCore({ type: 'remaining' }); - const state = core.getState(createMediaState()); + core.setMedia(createMediaState()); + const state = core.getState(); expect(core.getLabel(state)).toBe('Remaining'); }); it('returns custom string label', () => { const core = new TimeCore({ type: 'current', label: 'Position' }); - const state = core.getState(createMediaState()); + core.setMedia(createMediaState()); + const state = core.getState(); expect(core.getLabel(state)).toBe('Position'); }); @@ -120,7 +132,8 @@ describe('TimeCore', () => { type: 'current', label: (state) => `Time: ${state.text}`, }); - const state = core.getState(createMediaState({ currentTime: 90 })); + core.setMedia(createMediaState({ currentTime: 90 })); + const state = core.getState(); expect(core.getLabel(state)).toBe('Time: 1:30'); }); }); @@ -128,7 +141,8 @@ describe('TimeCore', () => { describe('getAttrs', () => { it('returns aria attributes', () => { const core = new TimeCore({ type: 'current' }); - const state = core.getState(createMediaState({ currentTime: 90 })); + core.setMedia(createMediaState({ currentTime: 90 })); + const state = core.getState(); const attrs = core.getAttrs(state); expect(attrs['aria-label']).toBe('Current time'); @@ -137,7 +151,8 @@ describe('TimeCore', () => { it('includes remaining suffix in valuetext', () => { const core = new TimeCore({ type: 'remaining' }); - const state = core.getState(createMediaState({ currentTime: 90, duration: 300 })); + core.setMedia(createMediaState({ currentTime: 90, duration: 300 })); + const state = core.getState(); const attrs = core.getAttrs(state); expect(attrs['aria-label']).toBe('Remaining'); diff --git a/packages/core/src/core/ui/time/time-core.ts b/packages/core/src/core/ui/time/time-core.ts index 57fd2d91..818c402e 100644 --- a/packages/core/src/core/ui/time/time-core.ts +++ b/packages/core/src/core/ui/time/time-core.ts @@ -46,6 +46,7 @@ export class TimeCore { }; #props = { ...TimeCore.defaultProps }; + #media: MediaTimeState | null = null; constructor(props?: TimeProps) { if (props) this.setProps(props); @@ -55,7 +56,12 @@ export class TimeCore { this.#props = defaults(props, TimeCore.defaultProps); } - #getSeconds(media: MediaTimeState): number { + setMedia(media: MediaTimeState): void { + this.#media = media; + } + + #getSeconds(): number { + const media = this.#media!; const { type } = this.#props; switch (type) { case 'current': @@ -69,14 +75,15 @@ export class TimeCore { } } - #getText(media: MediaTimeState): string { - const seconds = this.#getSeconds(media); + #getText(): string { + const media = this.#media!; + const seconds = this.#getSeconds(); return formatTime(Math.abs(seconds), media.duration); } - #getPhrase(media: MediaTimeState): string { + #getPhrase(): string { const { type } = this.#props; - const seconds = this.#getSeconds(media); + const seconds = this.#getSeconds(); if (type === 'remaining') { // Use negative to trigger "remaining" suffix @@ -86,8 +93,8 @@ export class TimeCore { return formatTimeAsPhrase(seconds); } - #getDatetime(media: MediaTimeState): string { - const seconds = this.#getSeconds(media); + #getDatetime(): string { + const seconds = this.#getSeconds(); return secondsToIsoDuration(Math.abs(seconds)); } @@ -111,15 +118,15 @@ export class TimeCore { }; } - getState(media: MediaTimeState): TimeState { - const seconds = this.#getSeconds(media); + getState(): TimeState { + const seconds = this.#getSeconds(); return { type: this.#props.type, seconds, negative: this.#props.type === 'remaining' && seconds < 0, - text: this.#getText(media), - phrase: this.#getPhrase(media), - datetime: this.#getDatetime(media), + text: this.#getText(), + phrase: this.#getPhrase(), + datetime: this.#getDatetime(), }; } } diff --git a/packages/core/src/core/ui/types.ts b/packages/core/src/core/ui/types.ts index 49fa8ea2..c1124e06 100644 --- a/packages/core/src/core/ui/types.ts +++ b/packages/core/src/core/ui/types.ts @@ -1,3 +1,21 @@ export type StateAttrMap = { [Key in keyof State]?: string; }; + +/** Constraint for core UI classes that compute component state. */ +export interface UIComponent { + getState(): State; + setProps?(props: Props): void; + getAttrs?(state: State): object; +} + +/** Constraint for core UI classes that derive component state from media state. */ +export interface MediaUIComponent extends UIComponent { + setMedia(media: object): void; +} + +/** Extracts the media state parameter type from a core's `setMedia` method. */ +export type InferMediaState = Parameters[0]; + +/** Extracts the component state return type from a core's `getState` method. */ +export type InferComponentState = ReturnType; diff --git a/packages/core/src/core/ui/volume-slider/tests/volume-slider-core.test.ts b/packages/core/src/core/ui/volume-slider/tests/volume-slider-core.test.ts index 3ccbd969..96d57606 100644 --- a/packages/core/src/core/ui/volume-slider/tests/volume-slider-core.test.ts +++ b/packages/core/src/core/ui/volume-slider/tests/volume-slider-core.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it, vi } from 'vitest'; import type { MediaVolumeState } from '../../../media/state'; -import type { SliderInteraction } from '../../slider/slider-core'; +import type { SliderInput } from '../../slider/slider-core'; import { VolumeSliderCore } from '../volume-slider-core'; -function createInteraction(overrides: Partial = {}): SliderInteraction { +function createInput(overrides: Partial = {}): SliderInput { return { pointerPercent: 0, dragPercent: 0, @@ -30,20 +30,25 @@ describe('VolumeSliderCore', () => { describe('defaultProps', () => { it('has expected defaults', () => { expect(VolumeSliderCore.defaultProps).toEqual({ + label: 'Volume', step: 1, largeStep: 10, orientation: 'horizontal', disabled: false, thumbAlignment: 'center', - label: 'Volume', + value: 0, + min: 0, + max: 100, }); }); }); - describe('getVolumeState', () => { + describe('getState', () => { it('maps volume 0-1 to 0-100 percent', () => { const core = new VolumeSliderCore(); - const state = core.getVolumeState(createMediaState({ volume: 0.75 }), createInteraction()); + core.setInput(createInput()); + core.setMedia(createMediaState({ volume: 0.75 })); + const state = core.getState(); expect(state.value).toBe(75); expect(state.fillPercent).toBe(75); @@ -52,7 +57,9 @@ describe('VolumeSliderCore', () => { it('returns 0 when volume is 0', () => { const core = new VolumeSliderCore(); - const state = core.getVolumeState(createMediaState({ volume: 0 }), createInteraction()); + core.setInput(createInput()); + core.setMedia(createMediaState({ volume: 0 })); + const state = core.getState(); expect(state.value).toBe(0); expect(state.fillPercent).toBe(0); @@ -60,7 +67,9 @@ describe('VolumeSliderCore', () => { it('returns 100 when volume is 1', () => { const core = new VolumeSliderCore(); - const state = core.getVolumeState(createMediaState({ volume: 1 }), createInteraction()); + core.setInput(createInput()); + core.setMedia(createMediaState({ volume: 1 })); + const state = core.getState(); expect(state.value).toBe(100); expect(state.fillPercent).toBe(100); @@ -68,7 +77,9 @@ describe('VolumeSliderCore', () => { it('sets fillPercent to 0 when muted', () => { const core = new VolumeSliderCore(); - const state = core.getVolumeState(createMediaState({ volume: 0.75, muted: true }), createInteraction()); + core.setInput(createInput()); + core.setMedia(createMediaState({ volume: 0.75, muted: true })); + const state = core.getState(); expect(state.value).toBe(75); expect(state.fillPercent).toBe(0); @@ -77,10 +88,9 @@ describe('VolumeSliderCore', () => { it('uses drag percent for value when dragging', () => { const core = new VolumeSliderCore(); - const state = core.getVolumeState( - createMediaState({ volume: 0.75 }), - createInteraction({ dragging: true, dragPercent: 40 }) - ); + core.setInput(createInput({ dragging: true, dragPercent: 40 })); + core.setMedia(createMediaState({ volume: 0.75 })); + const state = core.getState(); expect(state.value).toBe(40); expect(state.dragging).toBe(true); @@ -89,7 +99,9 @@ describe('VolumeSliderCore', () => { it('preserves muted state', () => { const core = new VolumeSliderCore(); - const state = core.getVolumeState(createMediaState({ volume: 0.5, muted: false }), createInteraction()); + core.setInput(createInput()); + core.setMedia(createMediaState({ volume: 0.5, muted: false })); + const state = core.getState(); expect(state.muted).toBe(false); }); @@ -98,7 +110,9 @@ describe('VolumeSliderCore', () => { describe('getAttrs', () => { it('returns aria-label and aria-valuetext', () => { const core = new VolumeSliderCore(); - const state = core.getVolumeState(createMediaState({ volume: 0.75 }), createInteraction()); + core.setInput(createInput()); + core.setMedia(createMediaState({ volume: 0.75 })); + const state = core.getState(); const attrs = core.getAttrs(state); expect(attrs['aria-label']).toBe('Volume'); @@ -108,7 +122,9 @@ describe('VolumeSliderCore', () => { it('includes muted in valuetext when muted', () => { const core = new VolumeSliderCore(); - const state = core.getVolumeState(createMediaState({ volume: 0.5, muted: true }), createInteraction()); + core.setInput(createInput()); + core.setMedia(createMediaState({ volume: 0.5, muted: true })); + const state = core.getState(); const attrs = core.getAttrs(state); expect(attrs['aria-valuetext']).toBe('50 percent, muted'); @@ -116,7 +132,9 @@ describe('VolumeSliderCore', () => { it('rounds value in valuetext', () => { const core = new VolumeSliderCore(); - const state = core.getVolumeState(createMediaState({ volume: 0.333 }), createInteraction()); + core.setInput(createInput()); + core.setMedia(createMediaState({ volume: 0.333 })); + const state = core.getState(); const attrs = core.getAttrs(state); expect(attrs['aria-valuetext']).toBe('33 percent'); @@ -124,7 +142,9 @@ describe('VolumeSliderCore', () => { it('uses custom label', () => { const core = new VolumeSliderCore({ label: 'Audio' }); - const state = core.getVolumeState(createMediaState({ volume: 1 }), createInteraction()); + core.setInput(createInput()); + core.setMedia(createMediaState({ volume: 1 })); + const state = core.getState(); const attrs = core.getAttrs(state); expect(attrs['aria-label']).toBe('Audio'); @@ -132,7 +152,9 @@ describe('VolumeSliderCore', () => { it('shows 0 percent when volume is 0', () => { const core = new VolumeSliderCore(); - const state = core.getVolumeState(createMediaState({ volume: 0 }), createInteraction()); + core.setInput(createInput()); + core.setMedia(createMediaState({ volume: 0 })); + const state = core.getState(); const attrs = core.getAttrs(state); expect(attrs['aria-valuetext']).toBe('0 percent'); @@ -144,7 +166,9 @@ describe('VolumeSliderCore', () => { const core = new VolumeSliderCore(); core.setProps({ label: 'Sound' }); - const state = core.getVolumeState(createMediaState({ volume: 0.5 }), createInteraction()); + core.setInput(createInput()); + core.setMedia(createMediaState({ volume: 0.5 })); + const state = core.getState(); const attrs = core.getAttrs(state); expect(attrs['aria-label']).toBe('Sound'); @@ -152,7 +176,9 @@ describe('VolumeSliderCore', () => { it('respects disabled prop', () => { const core = new VolumeSliderCore({ disabled: true }); - const state = core.getVolumeState(createMediaState({ volume: 0.5 }), createInteraction()); + core.setInput(createInput()); + core.setMedia(createMediaState({ volume: 0.5 })); + const state = core.getState(); expect(state.disabled).toBe(true); diff --git a/packages/core/src/core/ui/volume-slider/volume-slider-core.ts b/packages/core/src/core/ui/volume-slider/volume-slider-core.ts index 61eacb63..239155a2 100644 --- a/packages/core/src/core/ui/volume-slider/volume-slider-core.ts +++ b/packages/core/src/core/ui/volume-slider/volume-slider-core.ts @@ -2,23 +2,28 @@ import { defaults } from '@videojs/utils/object'; import type { NonNullableObject } from '@videojs/utils/types'; import type { MediaVolumeState } from '../../media/state'; -import { type SliderBaseProps, SliderCore, type SliderInteraction, type SliderState } from '../slider/slider-core'; +import { SliderCore, type SliderProps, type SliderState } from '../slider/slider-core'; -export interface VolumeSliderProps extends SliderBaseProps {} +export interface VolumeSliderProps extends SliderProps { + /** @internal Derived from `volume` (0–100) — not user-settable. */ + value?: number | undefined; + /** @internal Always 0 — not user-settable. */ + min?: number | undefined; + /** @internal Always 100 — not user-settable. */ + max?: number | undefined; +} export interface VolumeSliderState extends SliderState, Pick {} -// @ts-expect-error — defaultProps shape differs from base (domain sliders omit value/min/max) +/** Volume-domain slider: maps media volume/mute state to slider state. */ export class VolumeSliderCore extends SliderCore { - static readonly defaultProps: NonNullableObject = { + static override readonly defaultProps: NonNullableObject = { + ...SliderCore.defaultProps, label: 'Volume', - step: SliderCore.defaultProps.step, - largeStep: SliderCore.defaultProps.largeStep, - orientation: SliderCore.defaultProps.orientation, - disabled: SliderCore.defaultProps.disabled, - thumbAlignment: SliderCore.defaultProps.thumbAlignment, }; + #media: MediaVolumeState | null = null; + constructor(props?: VolumeSliderProps) { super(); if (props) this.setProps(props); @@ -28,11 +33,17 @@ export class VolumeSliderCore extends SliderCore { super.setProps(defaults(props, VolumeSliderCore.defaultProps)); } - getVolumeState(media: MediaVolumeState, interaction: SliderInteraction): VolumeSliderState { + setMedia(media: MediaVolumeState): void { + this.#media = media; + } + + getState(): VolumeSliderState { + const media = this.#media!; const { volume, muted } = media; + const { dragging, dragPercent } = this.input; const volumePercent = volume * 100; - const value = interaction.dragging ? this.valueFromPercent(interaction.dragPercent) : volumePercent; - const base = super.getState(interaction, value); + const value = dragging ? this.valueFromPercent(dragPercent) : volumePercent; + const base = super.getSliderState(value); return { ...base, diff --git a/packages/core/src/dom/store/features/buffer.ts b/packages/core/src/dom/store/features/buffer.ts index 05a7b640..25294ce7 100644 --- a/packages/core/src/dom/store/features/buffer.ts +++ b/packages/core/src/dom/store/features/buffer.ts @@ -4,6 +4,7 @@ import type { MediaBufferState } from '../../../core/media/state'; import { definePlayerFeature } from '../../feature'; export const bufferFeature = definePlayerFeature({ + name: 'buffer', state: (): MediaBufferState => ({ buffered: [], seekable: [], diff --git a/packages/core/src/dom/store/features/controls.ts b/packages/core/src/dom/store/features/controls.ts index 170a510e..da3e967e 100644 --- a/packages/core/src/dom/store/features/controls.ts +++ b/packages/core/src/dom/store/features/controls.ts @@ -8,6 +8,7 @@ const IDLE_DELAY = 2000; const TAP_THRESHOLD = 250; export const controlsFeature = definePlayerFeature({ + name: 'controls', state: (): MediaControlsState => ({ userActive: true, controlsVisible: true, diff --git a/packages/core/src/dom/store/features/fullscreen.ts b/packages/core/src/dom/store/features/fullscreen.ts index a9ce3ccd..a07dfcdc 100644 --- a/packages/core/src/dom/store/features/fullscreen.ts +++ b/packages/core/src/dom/store/features/fullscreen.ts @@ -12,6 +12,7 @@ import { exitPiP, isPiPActive } from '../../presentation/pip'; import type { WebKitVideoElement } from '../../presentation/types'; export const fullscreenFeature = definePlayerFeature({ + name: 'fullscreen', state: ({ target }): MediaFullscreenState => ({ fullscreen: false, fullscreenAvailability: 'unavailable', diff --git a/packages/core/src/dom/store/features/pip.ts b/packages/core/src/dom/store/features/pip.ts index 7eaeaf66..6236f8f0 100644 --- a/packages/core/src/dom/store/features/pip.ts +++ b/packages/core/src/dom/store/features/pip.ts @@ -7,6 +7,7 @@ import { enterPiP, exitPiP, isPiPActive, isPiPSupported } from '../../presentati import type { WebKitVideoElement } from '../../presentation/types'; export const pipFeature = definePlayerFeature({ + name: 'pip', state: ({ target }): MediaPictureInPictureState => ({ pip: false, pipAvailability: 'unavailable', diff --git a/packages/core/src/dom/store/features/playback-rate.ts b/packages/core/src/dom/store/features/playback-rate.ts index 6aff6f0b..8437f29d 100644 --- a/packages/core/src/dom/store/features/playback-rate.ts +++ b/packages/core/src/dom/store/features/playback-rate.ts @@ -6,6 +6,7 @@ import { definePlayerFeature } from '../../feature'; const DEFAULT_RATES: readonly number[] = [1, 1.2, 1.5, 1.7, 2]; export const playbackRateFeature = definePlayerFeature({ + name: 'playbackRate', state: ({ target }): MediaPlaybackRateState => ({ playbackRates: DEFAULT_RATES, playbackRate: 1, diff --git a/packages/core/src/dom/store/features/playback.ts b/packages/core/src/dom/store/features/playback.ts index 9ee74845..deb40d78 100644 --- a/packages/core/src/dom/store/features/playback.ts +++ b/packages/core/src/dom/store/features/playback.ts @@ -4,6 +4,7 @@ import type { MediaPlaybackState } from '../../../core/media/state'; import { definePlayerFeature } from '../../feature'; export const playbackFeature = definePlayerFeature({ + name: 'playback', state: ({ target }): MediaPlaybackState => ({ paused: true, ended: false, diff --git a/packages/core/src/dom/store/features/source.ts b/packages/core/src/dom/store/features/source.ts index 2c58bd24..a598e0b7 100644 --- a/packages/core/src/dom/store/features/source.ts +++ b/packages/core/src/dom/store/features/source.ts @@ -4,6 +4,7 @@ import type { MediaSourceState } from '../../../core/media/state'; import { definePlayerFeature } from '../../feature'; export const sourceFeature = definePlayerFeature({ + name: 'source', state: ({ target, signals }): MediaSourceState => ({ source: null, canPlay: false, diff --git a/packages/core/src/dom/store/features/text-track.ts b/packages/core/src/dom/store/features/text-track.ts index 020a5cd8..2df87e6b 100644 --- a/packages/core/src/dom/store/features/text-track.ts +++ b/packages/core/src/dom/store/features/text-track.ts @@ -4,6 +4,7 @@ import type { MediaTextCue, MediaTextTrackState } from '../../../core/media/stat import { definePlayerFeature } from '../../feature'; export const textTrackFeature = definePlayerFeature({ + name: 'textTrack', state: (): MediaTextTrackState => ({ chaptersCues: [], thumbnailCues: [], diff --git a/packages/core/src/dom/store/features/time.ts b/packages/core/src/dom/store/features/time.ts index 366cb1ac..495812a9 100644 --- a/packages/core/src/dom/store/features/time.ts +++ b/packages/core/src/dom/store/features/time.ts @@ -6,6 +6,7 @@ import { hasMetadata } from '../../media/predicate'; import { signalKeys } from '../signal-keys'; export const timeFeature = definePlayerFeature({ + name: 'time', state: ({ target, signals }): MediaTimeState => ({ currentTime: 0, duration: 0, diff --git a/packages/core/src/dom/store/features/volume.ts b/packages/core/src/dom/store/features/volume.ts index d30bf9f6..5eb45c3d 100644 --- a/packages/core/src/dom/store/features/volume.ts +++ b/packages/core/src/dom/store/features/volume.ts @@ -4,6 +4,7 @@ import type { MediaFeatureAvailability, MediaVolumeState } from '../../../core/m import { definePlayerFeature } from '../../feature'; export const volumeFeature = definePlayerFeature({ + name: 'volume', state: ({ target }): MediaVolumeState => ({ volume: 1, muted: false, diff --git a/packages/core/src/dom/ui/popover/popover.ts b/packages/core/src/dom/ui/popover/popover.ts index 54458072..dcd7780e 100644 --- a/packages/core/src/dom/ui/popover/popover.ts +++ b/packages/core/src/dom/ui/popover/popover.ts @@ -1,8 +1,8 @@ import type { State } from '@videojs/store'; import { listen } from '@videojs/utils/dom'; -import type { PopoverInteraction } from '../../../core/ui/popover/popover-core'; +import type { PopoverInput } from '../../../core/ui/popover/popover-core'; import type { UIFocusEvent, UIPointerEvent } from '../event'; -import type { TransitionHandler } from '../transition'; +import type { TransitionApi } from '../transition'; export type PopoverOpenChangeReason = 'click' | 'hover' | 'focus' | 'escape' | 'outside-click' | 'blur'; @@ -12,7 +12,7 @@ export interface PopoverChangeDetails { } export interface PopoverOptions { - transition: TransitionHandler; + transition: TransitionApi; onOpenChange: (open: boolean, details: PopoverChangeDetails) => void; /** Fires after open/close animations complete. */ onOpenChangeComplete?: (open: boolean) => void; @@ -37,8 +37,8 @@ export interface PopoverPopupProps { onFocusOut: (event: UIFocusEvent) => void; } -export interface PopoverHandle { - interaction: State; +export interface PopoverApi { + input: State; triggerProps: PopoverTriggerProps; popupProps: PopoverPopupProps; readonly triggerElement: HTMLElement | null; @@ -49,7 +49,7 @@ export interface PopoverHandle { destroy: () => void; } -export function createPopover(options: PopoverOptions): PopoverHandle { +export function createPopover(options: PopoverOptions): PopoverApi { const { transition, onOpenChange, closeOnEscape, closeOnOutsideClick } = options; const state = transition.state; @@ -299,7 +299,7 @@ export function createPopover(options: PopoverOptions): PopoverHandle { popupEl = el; if (el) { - // If the interaction is already open (e.g., React mount after state + // If the popover is already open (e.g., React mount after state // change), show the popover now. In `applyOpen` the element may not // have been in the DOM yet, so the earlier `tryShowPopover` was a no-op. if (state.current.active) { @@ -316,7 +316,7 @@ export function createPopover(options: PopoverOptions): PopoverHandle { } return { - interaction: state, + input: state, triggerProps, popupProps, get triggerElement() { diff --git a/packages/core/src/dom/ui/popover/tests/popover-helpers.ts b/packages/core/src/dom/ui/popover/tests/popover-helpers.ts index 4a448482..9caaeab8 100644 --- a/packages/core/src/dom/ui/popover/tests/popover-helpers.ts +++ b/packages/core/src/dom/ui/popover/tests/popover-helpers.ts @@ -1,10 +1,10 @@ import { vi } from 'vitest'; -import { createTransitionHandler } from '../../transition'; +import { createTransition } from '../../transition'; import { createPopover, type PopoverChangeDetails } from '../popover'; export function createTestPopover(overrides?: Partial[0]>) { const onOpenChange = vi.fn<(open: boolean, details: PopoverChangeDetails) => void>(); - const transition = overrides?.transition ?? createTransitionHandler(); + const transition = overrides?.transition ?? createTransition(); const popover = createPopover({ transition, onOpenChange, diff --git a/packages/core/src/dom/ui/popover/tests/popover.test.ts b/packages/core/src/dom/ui/popover/tests/popover.test.ts index 26de6240..f84cd92e 100644 --- a/packages/core/src/dom/ui/popover/tests/popover.test.ts +++ b/packages/core/src/dom/ui/popover/tests/popover.test.ts @@ -5,16 +5,16 @@ import { createTestPopover } from './popover-helpers'; describe('createPopover', () => { it('starts closed', () => { const { popover } = createTestPopover(); - expect(popover.interaction.current).toEqual({ active: false, status: 'idle' }); + expect(popover.input.current).toEqual({ active: false, status: 'idle' }); }); describe('open/close', () => { - it('updates interaction state and calls onOpenChange when opening', () => { + it('updates input state and calls onOpenChange when opening', () => { const { popover, onOpenChange } = createTestPopover(); popover.open(); - expect(popover.interaction.current.active).toBe(true); + expect(popover.input.current.active).toBe(true); expect(onOpenChange).toHaveBeenCalledWith(true, { reason: 'click' }); }); @@ -23,7 +23,7 @@ describe('createPopover', () => { popover.open(); - expect(popover.interaction.current).toEqual({ active: true, status: 'starting' }); + expect(popover.input.current).toEqual({ active: true, status: 'starting' }); }); it('calls onOpenChange when closing', () => { @@ -35,7 +35,7 @@ describe('createPopover', () => { popover.close(); // active stays true until close animation completes - expect(popover.interaction.current.active).toBe(true); + expect(popover.input.current.active).toBe(true); expect(onOpenChange).toHaveBeenCalledWith(false, { reason: 'click' }); }); @@ -45,7 +45,7 @@ describe('createPopover', () => { popover.open(); popover.close(); - expect(popover.interaction.current).toEqual({ active: true, status: 'ending' }); + expect(popover.input.current).toEqual({ active: true, status: 'ending' }); }); it('does not call onOpenChange if already open', () => { @@ -95,7 +95,7 @@ describe('createPopover', () => { popover.triggerProps.onClick(event); - expect(popover.interaction.current.active).toBe(true); + expect(popover.input.current.active).toBe(true); expect(onOpenChange).toHaveBeenCalledWith(true, expect.objectContaining({ reason: 'click' })); }); @@ -108,7 +108,7 @@ describe('createPopover', () => { popover.triggerProps.onClick({ preventDefault: vi.fn() } as unknown as UIEvent); // active stays true until close animation completes - expect(popover.interaction.current.active).toBe(true); + expect(popover.input.current.active).toBe(true); expect(onOpenChange).toHaveBeenCalledWith(false, expect.objectContaining({ reason: 'click' })); }); @@ -122,8 +122,8 @@ describe('createPopover', () => { // Click during close animation should re-open popover.triggerProps.onClick({ preventDefault: vi.fn() } as unknown as UIEvent); - expect(popover.interaction.current.active).toBe(true); - expect(popover.interaction.current.status).not.toBe('ending'); + expect(popover.input.current.active).toBe(true); + expect(popover.input.current.status).not.toBe('ending'); expect(onOpenChange).toHaveBeenCalledWith(true, expect.objectContaining({ reason: 'click' })); }); }); @@ -156,7 +156,7 @@ describe('createPopover', () => { popover.open(); expect(onOpenChange).not.toHaveBeenCalled(); - expect(popover.interaction.current.active).toBe(false); + expect(popover.input.current.active).toBe(false); }); }); @@ -165,13 +165,13 @@ describe('createPopover', () => { const { popover } = createTestPopover(); const callback = vi.fn(); - popover.interaction.subscribe(callback); + popover.input.subscribe(callback); popover.open(); flush(); expect(callback).toHaveBeenCalled(); - expect(popover.interaction.current.active).toBe(true); + expect(popover.input.current.active).toBe(true); }); }); }); diff --git a/packages/core/src/dom/ui/slider.ts b/packages/core/src/dom/ui/slider.ts index fc144c72..6993626b 100644 --- a/packages/core/src/dom/ui/slider.ts +++ b/packages/core/src/dom/ui/slider.ts @@ -3,7 +3,7 @@ import { listen } from '@videojs/utils/dom'; import { throttle } from '@videojs/utils/function'; import { clamp, roundToStep } from '@videojs/utils/number'; import { isNull } from '@videojs/utils/predicate'; -import type { SliderInteraction } from '../../core/ui/slider/slider-core'; +import type { SliderInput } from '../../core/ui/slider/slider-core'; import { getPercentFromPointerEvent } from '../utils/pointer'; import type { UIKeyboardEvent, UIPointerEvent } from './event'; @@ -50,8 +50,8 @@ export interface SliderThumbProps { onBlur: () => void; } -export interface SliderHandle { - interaction: State; +export interface SliderApi { + input: State; rootProps: SliderRootProps; thumbProps: SliderThumbProps; destroy: () => void; @@ -60,8 +60,8 @@ export interface SliderHandle { /** Intentional drag threshold — number of pointermove events before drag starts. */ const DRAG_THRESHOLD = 2; -export function createSlider(options: SliderOptions): SliderHandle { - const state = createState({ +export function createSlider(options: SliderOptions): SliderApi { + const input = createState({ pointerPercent: 0, dragPercent: 0, dragging: false, @@ -97,10 +97,10 @@ export function createSlider(options: SliderOptions): SliderHandle { function endDrag(): void { if (!isDragging) { - state.patch({ pointing: false, pointerPercent: 0 }); + input.patch({ pointing: false, pointerPercent: 0 }); } else { isDragging = false; - state.patch({ dragging: false, pointing: false, pointerPercent: 0 }); + input.patch({ dragging: false, pointing: false, pointerPercent: 0 }); options.onDragEnd?.(); } @@ -128,17 +128,17 @@ export function createSlider(options: SliderOptions): SliderHandle { if (!isDragging && moveCount >= DRAG_THRESHOLD) { isDragging = true; - state.patch({ dragging: true, dragPercent: percent, pointerPercent: percent }); + input.patch({ dragging: true, dragPercent: percent, pointerPercent: percent }); options.onDragStart?.(); options.onValueChange?.(percent); throttledCommit?.(percent); } else if (isDragging) { - state.patch({ dragPercent: percent, pointerPercent: percent }); + input.patch({ dragPercent: percent, pointerPercent: percent }); options.onValueChange?.(percent); throttledCommit?.(percent); } else { // Below drag threshold — update hover preview only. - state.patch({ pointerPercent: percent }); + input.patch({ pointerPercent: percent }); } } @@ -180,7 +180,7 @@ export function createSlider(options: SliderOptions): SliderHandle { const percent = getPercentFromPointerEvent(event, cachedRect, options.getOrientation(), cachedRTL); - state.patch({ pointing: true, pointerPercent: percent, dragPercent: percent }); + input.patch({ pointing: true, pointerPercent: percent, dragPercent: percent }); options.onValueChange?.(percent); // Focus the thumb for keyboard follow-up and screen reader tracking. @@ -197,12 +197,12 @@ export function createSlider(options: SliderOptions): SliderHandle { const rect = el.getBoundingClientRect(); const percent = getPercentFromPointerEvent(event, rect, options.getOrientation(), options.isRTL()); - state.patch({ pointing: true, pointerPercent: percent }); + input.patch({ pointing: true, pointerPercent: percent }); }, onPointerLeave() { if (isDragging) return; - state.patch({ pointing: false, pointerPercent: 0 }); + input.patch({ pointing: false, pointerPercent: 0 }); }, }; @@ -266,25 +266,25 @@ export function createSlider(options: SliderOptions): SliderHandle { if (newPercent !== null) { event.preventDefault(); newPercent = clamp(newPercent, 0, 100); - state.patch({ pointerPercent: newPercent, dragPercent: newPercent }); + input.patch({ pointerPercent: newPercent, dragPercent: newPercent }); options.onValueChange?.(newPercent); options.onValueCommit?.(newPercent); } }, onFocus() { - state.patch({ focused: true }); + input.patch({ focused: true }); }, onBlur() { - state.patch({ focused: false }); + input.patch({ focused: false }); }, }; listen(abort.signal, 'abort', cleanup, { once: true }); return { - interaction: state, + input, rootProps, thumbProps, destroy() { diff --git a/packages/core/src/dom/ui/tests/slider.test.ts b/packages/core/src/dom/ui/tests/slider.test.ts index b76d7d3a..11f78a1e 100644 --- a/packages/core/src/dom/ui/tests/slider.test.ts +++ b/packages/core/src/dom/ui/tests/slider.test.ts @@ -95,12 +95,12 @@ describe('createSlider', () => { }); describe('shape', () => { - it('returns interaction, rootProps, thumbProps, and destroy', () => { + it('returns state, rootProps, thumbProps, and destroy', () => { const slider = createSlider(createOptions()); - expect(slider.interaction).toBeDefined(); - expect(slider.interaction.current).toBeDefined(); - expect(slider.interaction.subscribe).toBeTypeOf('function'); + expect(slider.input).toBeDefined(); + expect(slider.input.current).toBeDefined(); + expect(slider.input.subscribe).toBeTypeOf('function'); expect(slider.rootProps.onPointerDown).toBeTypeOf('function'); expect(slider.rootProps.onPointerMove).toBeTypeOf('function'); expect(slider.rootProps.onPointerLeave).toBeTypeOf('function'); @@ -112,10 +112,10 @@ describe('createSlider', () => { slider.destroy(); }); - it('has correct initial interaction state', () => { + it('has correct initial state', () => { const slider = createSlider(createOptions()); - expect(slider.interaction.current).toEqual({ + expect(slider.input.current).toEqual({ pointerPercent: 0, dragPercent: 0, dragging: false, @@ -135,8 +135,8 @@ describe('createSlider', () => { slider.rootProps.onPointerDown(pointerEvent({ clientX: 100 })); flush(); - expect(slider.interaction.current.pointing).toBe(true); - expect(slider.interaction.current.pointerPercent).toBe(50); + expect(slider.input.current.pointing).toBe(true); + expect(slider.input.current.pointerPercent).toBe(50); slider.destroy(); }); @@ -185,7 +185,7 @@ describe('createSlider', () => { flush(); expect(onValueChange).not.toHaveBeenCalled(); - expect(slider.interaction.current.pointing).toBe(false); + expect(slider.input.current.pointing).toBe(false); slider.destroy(); }); @@ -202,13 +202,13 @@ describe('createSlider', () => { // First move — below threshold fireDocumentPointerMove({ clientX: 60 }); flush(); - expect(slider.interaction.current.dragging).toBe(false); + expect(slider.input.current.dragging).toBe(false); expect(onDragStart).not.toHaveBeenCalled(); // Second move — meets threshold fireDocumentPointerMove({ clientX: 80 }); flush(); - expect(slider.interaction.current.dragging).toBe(true); + expect(slider.input.current.dragging).toBe(true); expect(onDragStart).toHaveBeenCalledOnce(); slider.destroy(); @@ -246,7 +246,7 @@ describe('createSlider', () => { fireDocumentPointerMove({ clientX: 100 }); flush(); - expect(slider.interaction.current.dragPercent).toBe(50); + expect(slider.input.current.dragPercent).toBe(50); slider.destroy(); }); @@ -267,8 +267,8 @@ describe('createSlider', () => { expect(onValueCommit).toHaveBeenCalledWith(50); expect(onDragEnd).toHaveBeenCalled(); - expect(slider.interaction.current.dragging).toBe(false); - expect(slider.interaction.current.pointing).toBe(false); + expect(slider.input.current.dragging).toBe(false); + expect(slider.input.current.pointing).toBe(false); slider.destroy(); }); @@ -312,7 +312,7 @@ describe('createSlider', () => { flush(); expect(onDragEnd).toHaveBeenCalled(); - expect(slider.interaction.current.dragging).toBe(false); + expect(slider.input.current.dragging).toBe(false); slider.destroy(); }); @@ -340,13 +340,13 @@ describe('createSlider', () => { fireDocumentPointerMove({ clientX: 60 }); fireDocumentPointerMove({ clientX: 80 }); flush(); - expect(slider.interaction.current.dragging).toBe(true); + expect(slider.input.current.dragging).toBe(true); // Stale: buttons = 0, mouse pointer fireDocumentPointerMove({ clientX: 100, buttons: 0, pointerType: 'mouse' }); flush(); - expect(slider.interaction.current.dragging).toBe(false); + expect(slider.input.current.dragging).toBe(false); expect(onDragEnd).toHaveBeenCalled(); slider.destroy(); @@ -374,13 +374,13 @@ describe('createSlider', () => { fireDocumentPointerMove({ clientX: 60 }); fireDocumentPointerMove({ clientX: 80 }); flush(); - expect(slider.interaction.current.dragging).toBe(true); + expect(slider.input.current.dragging).toBe(true); // Touch with buttons=0 should NOT trigger stale drag detection fireDocumentPointerMove({ clientX: 100, buttons: 0, pointerType: 'touch' }); flush(); - expect(slider.interaction.current.dragging).toBe(true); + expect(slider.input.current.dragging).toBe(true); expect(onDragEnd).not.toHaveBeenCalled(); slider.destroy(); @@ -395,8 +395,8 @@ describe('createSlider', () => { slider.rootProps.onPointerMove(pointerEvent({ clientX: 60 })); flush(); - expect(slider.interaction.current.pointing).toBe(true); - expect(slider.interaction.current.pointerPercent).toBe(30); + expect(slider.input.current.pointing).toBe(true); + expect(slider.input.current.pointerPercent).toBe(30); slider.destroy(); }); @@ -409,8 +409,8 @@ describe('createSlider', () => { slider.rootProps.onPointerLeave(pointerEvent()); flush(); - expect(slider.interaction.current.pointing).toBe(false); - expect(slider.interaction.current.pointerPercent).toBe(0); + expect(slider.input.current.pointing).toBe(false); + expect(slider.input.current.pointerPercent).toBe(0); slider.destroy(); }); @@ -423,12 +423,12 @@ describe('createSlider', () => { fireDocumentPointerMove({ clientX: 60 }); fireDocumentPointerMove({ clientX: 80 }); flush(); - expect(slider.interaction.current.dragging).toBe(true); + expect(slider.input.current.dragging).toBe(true); slider.rootProps.onPointerLeave(pointerEvent()); flush(); - expect(slider.interaction.current.pointing).toBe(true); + expect(slider.input.current.pointing).toBe(true); slider.destroy(); }); @@ -761,7 +761,7 @@ describe('createSlider', () => { slider.thumbProps.onFocus(); flush(); - expect(slider.interaction.current.focused).toBe(true); + expect(slider.input.current.focused).toBe(true); slider.destroy(); }); @@ -773,7 +773,7 @@ describe('createSlider', () => { slider.thumbProps.onBlur(); flush(); - expect(slider.interaction.current.focused).toBe(false); + expect(slider.input.current.focused).toBe(false); slider.destroy(); }); @@ -788,7 +788,7 @@ describe('createSlider', () => { slider.rootProps.onPointerDown(pointerEvent({ clientY: 25 })); flush(); - expect(slider.interaction.current.pointerPercent).toBe(75); + expect(slider.input.current.pointerPercent).toBe(75); slider.destroy(); }); @@ -800,7 +800,7 @@ describe('createSlider', () => { slider.rootProps.onPointerDown(pointerEvent({ clientX: 50 })); flush(); - expect(slider.interaction.current.pointerPercent).toBe(25); + expect(slider.input.current.pointerPercent).toBe(25); slider.destroy(); }); @@ -817,7 +817,7 @@ describe('createSlider', () => { flush(); // Same result as vertical + LTR — RTL has no effect. - expect(slider.interaction.current.pointerPercent).toBe(75); + expect(slider.input.current.pointerPercent).toBe(75); slider.destroy(); }); @@ -833,7 +833,7 @@ describe('createSlider', () => { slider.rootProps.onPointerDown(pointerEvent({ clientX: 50 })); flush(); - expect(slider.interaction.current.pointerPercent).toBe(75); + expect(slider.input.current.pointerPercent).toBe(75); slider.destroy(); }); diff --git a/packages/core/src/dom/ui/tests/transition.test.ts b/packages/core/src/dom/ui/tests/transition.test.ts index a723808b..2bc64395 100644 --- a/packages/core/src/dom/ui/tests/transition.test.ts +++ b/packages/core/src/dom/ui/tests/transition.test.ts @@ -1,15 +1,15 @@ import { describe, expect, it, vi } from 'vitest'; -import { createTransitionHandler } from '../transition'; +import { createTransition } from '../transition'; -describe('createTransitionHandler', () => { +describe('createTransition', () => { it('starts with idle state', () => { - const handler = createTransitionHandler(); + const handler = createTransition(); expect(handler.state.current).toEqual({ active: false, status: 'idle' }); }); describe('open', () => { it('patches open and starting status synchronously', () => { - const handler = createTransitionHandler(); + const handler = createTransition(); handler.open(); @@ -17,7 +17,7 @@ describe('createTransitionHandler', () => { }); it('transitions to idle after one RAF', async () => { - const handler = createTransitionHandler(); + const handler = createTransition(); const promise = handler.open(); expect(handler.state.current.status).toBe('starting'); @@ -33,7 +33,7 @@ describe('createTransitionHandler', () => { describe('close', () => { it('patches ending status synchronously', () => { - const handler = createTransitionHandler(); + const handler = createTransition(); const el = document.createElement('div'); // Open first @@ -45,7 +45,7 @@ describe('createTransitionHandler', () => { }); it('keeps open true during close animation', () => { - const handler = createTransitionHandler(); + const handler = createTransition(); const el = document.createElement('div'); handler.open(); @@ -56,7 +56,7 @@ describe('createTransitionHandler', () => { }); it('handles null element gracefully', async () => { - const handler = createTransitionHandler(); + const handler = createTransition(); handler.open(); const promise = handler.close(null); @@ -74,7 +74,7 @@ describe('createTransitionHandler', () => { describe('cancel', () => { it('resets status to idle', () => { - const handler = createTransitionHandler(); + const handler = createTransition(); handler.open(); expect(handler.state.current.status).toBe('starting'); @@ -84,7 +84,7 @@ describe('createTransitionHandler', () => { }); it('preserves open state', () => { - const handler = createTransitionHandler(); + const handler = createTransition(); handler.open(); handler.cancel(); @@ -94,7 +94,7 @@ describe('createTransitionHandler', () => { }); it('is a no-op when already idle', () => { - const handler = createTransitionHandler(); + const handler = createTransition(); const callback = vi.fn(); handler.state.subscribe(callback); @@ -107,7 +107,7 @@ describe('createTransitionHandler', () => { describe('destroy', () => { it('prevents further open calls from updating state', () => { - const handler = createTransitionHandler(); + const handler = createTransition(); handler.destroy(); handler.open(); @@ -118,7 +118,7 @@ describe('createTransitionHandler', () => { }); it('is idempotent', () => { - const handler = createTransitionHandler(); + const handler = createTransition(); handler.destroy(); handler.destroy(); // should not throw diff --git a/packages/core/src/dom/ui/thumbnail.ts b/packages/core/src/dom/ui/thumbnail.ts index a6b9663b..abec5125 100644 --- a/packages/core/src/dom/ui/thumbnail.ts +++ b/packages/core/src/dom/ui/thumbnail.ts @@ -8,7 +8,7 @@ export interface CreateThumbnailOptions { onStateChange: () => void; } -export interface ThumbnailHandle { +export interface ThumbnailApi { readonly loading: boolean; readonly error: boolean; readonly naturalWidth: number; @@ -19,7 +19,7 @@ export interface ThumbnailHandle { destroy(): void; } -export function createThumbnail(options: CreateThumbnailOptions): ThumbnailHandle { +export function createThumbnail(options: CreateThumbnailOptions): ThumbnailApi { const { getContainer, getImg, onStateChange } = options; const core = new ThumbnailCore(); const abort = new AbortController(); diff --git a/packages/core/src/dom/ui/transition.ts b/packages/core/src/dom/ui/transition.ts index 6d329ee3..0e47f772 100644 --- a/packages/core/src/dom/ui/transition.ts +++ b/packages/core/src/dom/ui/transition.ts @@ -2,7 +2,7 @@ import { createState, type State } from '@videojs/store'; import { noop } from '@videojs/utils/function'; import type { TransitionState } from '../../core/ui/transition'; -export interface TransitionHandler { +export interface TransitionApi { state: State; open(): Promise; close(el: HTMLElement | null): Promise; @@ -21,7 +21,7 @@ export interface TransitionHandler { * element stays mounted), then after a double-RAF waits for * `getAnimations()` to settle before patching `{ active: false, status: 'idle' }`. */ -export function createTransitionHandler(): TransitionHandler { +export function createTransition(): TransitionApi { const state = createState({ active: false, status: 'idle' }); let destroyed = false; diff --git a/packages/core/src/dom/utils/element-props.ts b/packages/core/src/dom/utils/element-props.ts index 467bf150..c09c226f 100644 --- a/packages/core/src/dom/utils/element-props.ts +++ b/packages/core/src/dom/utils/element-props.ts @@ -10,7 +10,9 @@ import { isFunction, isUndefined } from '@videojs/utils/predicate'; * - `undefined` removes the attribute * - Other props are set as string attributes */ -export function applyElementProps(element: HTMLElement, props: object, signal?: AbortSignal): void { +export function applyElementProps(element: HTMLElement, props: object, options?: { signal?: AbortSignal }): void { + const signal = options?.signal; + for (const [key, value] of Object.entries(props)) { if (isFunction(value) && key.startsWith('on')) { const event = key.slice(2).toLowerCase(); diff --git a/packages/html/src/index.ts b/packages/html/src/index.ts index 2a7591e3..1e6821b8 100644 --- a/packages/html/src/index.ts +++ b/packages/html/src/index.ts @@ -17,8 +17,10 @@ export { BufferingIndicatorElement } from './ui/buffering-indicator/buffering-in export { ControlsElement } from './ui/controls/controls-element'; export { ControlsGroupElement } from './ui/controls/controls-group-element'; export { FullscreenButtonElement } from './ui/fullscreen-button/fullscreen-button-element'; +export { MediaButtonElement } from './ui/media-button-element'; // Primitives export * from './ui/media-element'; +export { MediaUIElement } from './ui/media-ui-element'; 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'; @@ -26,8 +28,8 @@ export { PlaybackRateButtonElement } from './ui/playback-rate-button/playback-ra export { PopoverElement } from './ui/popover/popover-element'; export { PosterElement } from './ui/poster/poster-element'; export { SeekButtonElement } from './ui/seek-button/seek-button-element'; +export { type SliderContextValue, sliderContext } from './ui/slider/context'; export { SliderBufferElement } from './ui/slider/slider-buffer-element'; -export { type SliderContextValue, sliderContext } from './ui/slider/slider-context'; export { SliderElement } from './ui/slider/slider-element'; export type { SliderEventMap, SliderValueEventDetail } from './ui/slider/slider-events'; export { SliderFillElement } from './ui/slider/slider-fill-element'; diff --git a/packages/html/src/player/player-controller.ts b/packages/html/src/player/player-controller.ts index 7e9428ac..3f7aee22 100644 --- a/packages/html/src/player/player-controller.ts +++ b/packages/html/src/player/player-controller.ts @@ -83,6 +83,10 @@ export class PlayerController impleme return this.#store?.value; } + get displayName(): string | undefined { + return this.#selector?.displayName; + } + hostConnected(): void { const store = this.#consumer.value; if (store) this.#connect(store); diff --git a/packages/html/src/ui/buffering-indicator/buffering-indicator-element.ts b/packages/html/src/ui/buffering-indicator/buffering-indicator-element.ts index ddf10b18..00961aa4 100644 --- a/packages/html/src/ui/buffering-indicator/buffering-indicator-element.ts +++ b/packages/html/src/ui/buffering-indicator/buffering-indicator-element.ts @@ -30,7 +30,7 @@ export class BufferingIndicatorElement extends MediaElement { }); if (__DEV__ && !this.#state.value) { - logMissingFeature(BufferingIndicatorElement.tagName, 'playback'); + logMissingFeature(this.localName, this.#state.displayName!); } } diff --git a/packages/html/src/ui/context-part-element.ts b/packages/html/src/ui/context-part-element.ts new file mode 100644 index 00000000..8635d8cb --- /dev/null +++ b/packages/html/src/ui/context-part-element.ts @@ -0,0 +1,34 @@ +import type { StateAttrMap } from '@videojs/core'; +import { applyStateDataAttrs } from '@videojs/core/dom'; +import type { PropertyValues } from '@videojs/element'; + +import { MediaElement } from './media-element'; + +/** Shape that compound context values must satisfy for parts to consume. */ +export interface PartContextValue { + state: State; + stateAttrMap: StateAttrMap; +} + +/** + * Abstract base for compound-component part elements that consume a parent + * context and apply data attributes from `ctx.state` + `ctx.stateAttrMap`. + * + * Subclasses only need to declare the `consumer` property: + * + * ```ts + * export class SliderTrackElement extends ContextPartElement { + * static readonly tagName = 'media-slider-track'; + * protected readonly consumer = new ContextConsumer(this, { context: sliderContext, subscribe: true }); + * } + * ``` + */ +export abstract class ContextPartElement extends MediaElement { + protected abstract readonly consumer: { value?: PartContextValue | undefined }; + + protected override update(_changed: PropertyValues): void { + super.update(_changed); + const ctx = this.consumer.value; + if (ctx) applyStateDataAttrs(this, ctx.state, ctx.stateAttrMap); + } +} diff --git a/packages/html/src/ui/controls/context.ts b/packages/html/src/ui/controls/context.ts new file mode 100644 index 00000000..9cb43f30 --- /dev/null +++ b/packages/html/src/ui/controls/context.ts @@ -0,0 +1,11 @@ +import type { ControlsState, StateAttrMap } from '@videojs/core'; +import { createContext } from '@videojs/element/context'; + +export interface ControlsContextValue { + state: ControlsState; + stateAttrMap: StateAttrMap; +} + +const CONTROLS_CONTEXT_KEY = Symbol('@videojs/controls'); + +export const controlsContext = createContext(CONTROLS_CONTEXT_KEY); diff --git a/packages/html/src/ui/controls/controls-element.ts b/packages/html/src/ui/controls/controls-element.ts index 3710a17f..29830841 100644 --- a/packages/html/src/ui/controls/controls-element.ts +++ b/packages/html/src/ui/controls/controls-element.ts @@ -1,34 +1,42 @@ import { ControlsCore, ControlsDataAttrs } from '@videojs/core'; import { applyStateDataAttrs, logMissingFeature, selectControls } from '@videojs/core/dom'; import type { PropertyValues } from '@videojs/element'; +import { ContextProvider } from '@videojs/element/context'; import { playerContext } from '../../player/context'; import { PlayerController } from '../../player/player-controller'; import { MediaElement } from '../media-element'; +import { controlsContext } from './context'; export class ControlsElement extends MediaElement { static readonly tagName = 'media-controls'; readonly #core = new ControlsCore(); - readonly #state = new PlayerController(this, playerContext, selectControls); + readonly #mediaState = new PlayerController(this, playerContext, selectControls); + readonly #provider = new ContextProvider(this, { context: controlsContext }); override connectedCallback(): void { super.connectedCallback(); - if (__DEV__ && !this.#state.value) { - logMissingFeature(ControlsElement.tagName, 'controls'); + if (__DEV__ && !this.#mediaState.value && this.#mediaState.displayName) { + logMissingFeature(this.localName, this.#mediaState.displayName); } } - protected override update(changed: PropertyValues): void { - super.update(changed); + protected override update(_changed: PropertyValues): void { + super.update(_changed); - const controls = this.#state.value; + const media = this.#mediaState.value; + if (!media) return; - if (!controls) { - return; - } + this.#core.setMedia(media); + const state = this.#core.getState(); - applyStateDataAttrs(this, this.#core.getState(controls), ControlsDataAttrs); + applyStateDataAttrs(this, state, ControlsDataAttrs); + + this.#provider.setValue({ + state, + stateAttrMap: ControlsDataAttrs, + }); } } diff --git a/packages/html/src/ui/controls/controls-group-element.ts b/packages/html/src/ui/controls/controls-group-element.ts index 4d87ec56..7ed22711 100644 --- a/packages/html/src/ui/controls/controls-group-element.ts +++ b/packages/html/src/ui/controls/controls-group-element.ts @@ -1,8 +1,14 @@ -import { MediaElement } from '../media-element'; +import type { ControlsState } from '@videojs/core'; +import { ContextConsumer } from '@videojs/element/context'; -export class ControlsGroupElement extends MediaElement { +import { ContextPartElement } from '../context-part-element'; +import { controlsContext } from './context'; + +export class ControlsGroupElement extends ContextPartElement { static readonly tagName = 'media-controls-group'; + protected readonly consumer = new ContextConsumer(this, { context: controlsContext, subscribe: true }); + override connectedCallback(): void { super.connectedCallback(); diff --git a/packages/html/src/ui/fullscreen-button/fullscreen-button-element.ts b/packages/html/src/ui/fullscreen-button/fullscreen-button-element.ts index 9c744a00..340558a0 100644 --- a/packages/html/src/ui/fullscreen-button/fullscreen-button-element.ts +++ b/packages/html/src/ui/fullscreen-button/fullscreen-button-element.ts @@ -1,70 +1,18 @@ -import { FullscreenButtonCore, FullscreenButtonDataAttrs } from '@videojs/core'; -import { - applyElementProps, - applyStateDataAttrs, - createButton, - logMissingFeature, - selectFullscreen, -} from '@videojs/core/dom'; -import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; +import { FullscreenButtonCore, FullscreenButtonDataAttrs, type MediaFullscreenState } from '@videojs/core'; +import { selectFullscreen } from '@videojs/core/dom'; import { playerContext } from '../../player/context'; import { PlayerController } from '../../player/player-controller'; -import { MediaElement } from '../media-element'; +import { MediaButtonElement } from '../media-button-element'; -export class FullscreenButtonElement extends MediaElement { +export class FullscreenButtonElement extends MediaButtonElement { static readonly tagName = 'media-fullscreen-button'; - static override properties = { - label: { type: String }, - disabled: { type: Boolean }, - } satisfies PropertyDeclarationMap; + protected readonly core = new FullscreenButtonCore(); + protected readonly stateAttrMap = FullscreenButtonDataAttrs; + protected readonly mediaState = new PlayerController(this, playerContext, selectFullscreen); - label = FullscreenButtonCore.defaultProps.label; - disabled = FullscreenButtonCore.defaultProps.disabled; - - readonly #core = new FullscreenButtonCore(); - readonly #state = new PlayerController(this, playerContext, selectFullscreen); - - #disconnect: AbortController | null = null; - - override connectedCallback(): void { - super.connectedCallback(); - - this.#disconnect = new AbortController(); - - const buttonProps = createButton({ - onActivate: () => this.#core.toggle(this.#state.value!), - isDisabled: () => this.disabled || !this.#state.value, - }); - - applyElementProps(this, buttonProps, this.#disconnect.signal); - - if (__DEV__ && !this.#state.value) { - logMissingFeature(FullscreenButtonElement.tagName, 'fullscreen'); - } - } - - 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, FullscreenButtonDataAttrs); + protected activate(state: MediaFullscreenState): void { + this.core.toggle(state); } } diff --git a/packages/html/src/ui/media-button-element.ts b/packages/html/src/ui/media-button-element.ts new file mode 100644 index 00000000..c87054ef --- /dev/null +++ b/packages/html/src/ui/media-button-element.ts @@ -0,0 +1,66 @@ +import type { InferComponentState, InferMediaState, MediaUIComponent, StateAttrMap } from '@videojs/core'; +import { applyElementProps, applyStateDataAttrs, createButton, logMissingFeature } from '@videojs/core/dom'; +import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; + +import type { PlayerController } from '../player/player-controller'; +import { MediaElement } from './media-element'; + +/** Abstract base for HTML custom elements that render a media-control button. */ +export abstract class MediaButtonElement extends MediaElement { + static override properties: PropertyDeclarationMap = { + label: { type: String }, + disabled: { type: Boolean }, + }; + + disabled = false; + label = ''; + + protected abstract readonly core: Core; + protected abstract readonly stateAttrMap: StateAttrMap>; + protected abstract readonly mediaState: PlayerController | undefined>; + + protected abstract activate(state: InferMediaState): void; + + #disconnect: AbortController | null = null; + + override connectedCallback(): void { + super.connectedCallback(); + + this.#disconnect = new AbortController(); + + const buttonProps = createButton({ + onActivate: () => this.activate(this.mediaState.value!), + isDisabled: () => this.disabled || !this.mediaState.value, + }); + + applyElementProps(this, buttonProps, { signal: this.#disconnect.signal }); + + if (__DEV__ && !this.mediaState.value && this.mediaState.displayName) { + logMissingFeature(this.localName, this.mediaState.displayName); + } + } + + 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.mediaState.value; + + if (!media) return; + + this.core.setMedia(media); + const state = this.core.getState(); + applyElementProps(this, this.core.getAttrs?.(state) ?? {}); + applyStateDataAttrs(this, state, this.stateAttrMap); + } +} diff --git a/packages/html/src/ui/media-ui-element.ts b/packages/html/src/ui/media-ui-element.ts new file mode 100644 index 00000000..f5c31abf --- /dev/null +++ b/packages/html/src/ui/media-ui-element.ts @@ -0,0 +1,33 @@ +import type { InferComponentState, InferMediaState, MediaUIComponent, StateAttrMap } from '@videojs/core'; +import { applyStateDataAttrs, logMissingFeature } from '@videojs/core/dom'; +import type { PropertyValues } from '@videojs/element'; + +import type { PlayerController } from '../player/player-controller'; +import { MediaElement } from './media-element'; + +/** Abstract base for HTML custom elements that display media state with data attributes. */ +export abstract class MediaUIElement extends MediaElement { + protected abstract readonly core: Core; + protected abstract readonly stateAttrMap: StateAttrMap>; + protected abstract readonly mediaState: PlayerController | undefined>; + + override connectedCallback(): void { + super.connectedCallback(); + + if (__DEV__ && !this.mediaState.value && this.mediaState.displayName) { + logMissingFeature(this.localName, this.mediaState.displayName); + } + } + + protected override update(changed: PropertyValues): void { + super.update(changed); + + const media = this.mediaState.value; + + if (!media) return; + + this.core.setMedia(media); + const state = this.core.getState(); + applyStateDataAttrs(this, state, this.stateAttrMap); + } +} diff --git a/packages/html/src/ui/mute-button/mute-button-element.ts b/packages/html/src/ui/mute-button/mute-button-element.ts index f1ef7226..1c97aab4 100644 --- a/packages/html/src/ui/mute-button/mute-button-element.ts +++ b/packages/html/src/ui/mute-button/mute-button-element.ts @@ -1,70 +1,18 @@ -import { MuteButtonCore, MuteButtonDataAttrs } from '@videojs/core'; -import { - applyElementProps, - applyStateDataAttrs, - createButton, - logMissingFeature, - selectVolume, -} from '@videojs/core/dom'; -import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; +import { type MediaVolumeState, MuteButtonCore, MuteButtonDataAttrs } from '@videojs/core'; +import { selectVolume } from '@videojs/core/dom'; import { playerContext } from '../../player/context'; import { PlayerController } from '../../player/player-controller'; -import { MediaElement } from '../media-element'; +import { MediaButtonElement } from '../media-button-element'; -export class MuteButtonElement extends MediaElement { +export class MuteButtonElement extends MediaButtonElement { static readonly tagName = 'media-mute-button'; - static override properties = { - label: { type: String }, - disabled: { type: Boolean }, - } satisfies PropertyDeclarationMap; + protected readonly core = new MuteButtonCore(); + protected readonly stateAttrMap = MuteButtonDataAttrs; + protected readonly mediaState = new PlayerController(this, playerContext, selectVolume); - label = MuteButtonCore.defaultProps.label; - disabled = MuteButtonCore.defaultProps.disabled; - - readonly #core = new MuteButtonCore(); - readonly #state = new PlayerController(this, playerContext, selectVolume); - - #disconnect: AbortController | null = null; - - override connectedCallback(): void { - super.connectedCallback(); - - this.#disconnect = new AbortController(); - - const buttonProps = createButton({ - onActivate: () => this.#core.toggle(this.#state.value!), - isDisabled: () => this.disabled || !this.#state.value, - }); - - applyElementProps(this, buttonProps, this.#disconnect.signal); - - if (__DEV__ && !this.#state.value) { - logMissingFeature(MuteButtonElement.tagName, 'volume'); - } - } - - 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, MuteButtonDataAttrs); + protected activate(state: MediaVolumeState): void { + this.core.toggle(state); } } diff --git a/packages/html/src/ui/pip-button/pip-button-element.ts b/packages/html/src/ui/pip-button/pip-button-element.ts index 937065da..8793bfce 100644 --- a/packages/html/src/ui/pip-button/pip-button-element.ts +++ b/packages/html/src/ui/pip-button/pip-button-element.ts @@ -1,64 +1,18 @@ -import { PiPButtonCore, PiPButtonDataAttrs } from '@videojs/core'; -import { applyElementProps, applyStateDataAttrs, createButton, logMissingFeature, selectPiP } from '@videojs/core/dom'; -import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; +import { type MediaPictureInPictureState, PiPButtonCore, PiPButtonDataAttrs } from '@videojs/core'; +import { selectPiP } from '@videojs/core/dom'; import { playerContext } from '../../player/context'; import { PlayerController } from '../../player/player-controller'; -import { MediaElement } from '../media-element'; +import { MediaButtonElement } from '../media-button-element'; -export class PiPButtonElement extends MediaElement { +export class PiPButtonElement extends MediaButtonElement { static readonly tagName = 'media-pip-button'; - static override properties = { - label: { type: String }, - disabled: { type: Boolean }, - } satisfies PropertyDeclarationMap; + protected readonly core = new PiPButtonCore(); + protected readonly stateAttrMap = PiPButtonDataAttrs; + protected readonly mediaState = new PlayerController(this, playerContext, selectPiP); - label = PiPButtonCore.defaultProps.label; - disabled = PiPButtonCore.defaultProps.disabled; - - readonly #core = new PiPButtonCore(); - readonly #state = new PlayerController(this, playerContext, selectPiP); - - #disconnect: AbortController | null = null; - - override connectedCallback(): void { - super.connectedCallback(); - - this.#disconnect = new AbortController(); - - const buttonProps = createButton({ - onActivate: () => this.#core.toggle(this.#state.value!), - isDisabled: () => this.disabled || !this.#state.value, - }); - - applyElementProps(this, buttonProps, this.#disconnect.signal); - - if (__DEV__ && !this.#state.value) { - logMissingFeature(PiPButtonElement.tagName, 'pip'); - } - } - - 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, PiPButtonDataAttrs); + protected activate(state: MediaPictureInPictureState): void { + this.core.toggle(state); } } diff --git a/packages/html/src/ui/play-button/play-button-element.ts b/packages/html/src/ui/play-button/play-button-element.ts index f418a110..1b638292 100644 --- a/packages/html/src/ui/play-button/play-button-element.ts +++ b/packages/html/src/ui/play-button/play-button-element.ts @@ -1,70 +1,18 @@ -import { PlayButtonCore, PlayButtonDataAttrs } from '@videojs/core'; -import { - applyElementProps, - applyStateDataAttrs, - createButton, - logMissingFeature, - selectPlayback, -} from '@videojs/core/dom'; -import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; +import { type MediaPlaybackState, PlayButtonCore, PlayButtonDataAttrs } from '@videojs/core'; +import { selectPlayback } from '@videojs/core/dom'; import { playerContext } from '../../player/context'; import { PlayerController } from '../../player/player-controller'; -import { MediaElement } from '../media-element'; +import { MediaButtonElement } from '../media-button-element'; -export class PlayButtonElement extends MediaElement { +export class PlayButtonElement extends MediaButtonElement { static readonly tagName = 'media-play-button'; - static override properties = { - label: { type: String }, - disabled: { type: Boolean }, - } satisfies PropertyDeclarationMap; + protected readonly core = new PlayButtonCore(); + protected readonly stateAttrMap = PlayButtonDataAttrs; + protected readonly mediaState = new PlayerController(this, playerContext, selectPlayback); - label = PlayButtonCore.defaultProps.label; - disabled = PlayButtonCore.defaultProps.disabled; - - readonly #core = new PlayButtonCore(); - readonly #state = new PlayerController(this, playerContext, selectPlayback); - - #disconnect: AbortController | null = null; - - override connectedCallback(): void { - super.connectedCallback(); - - this.#disconnect = new AbortController(); - - const buttonProps = createButton({ - onActivate: () => this.#core.toggle(this.#state.value!), - isDisabled: () => this.disabled || !this.#state.value, - }); - - applyElementProps(this, buttonProps, this.#disconnect.signal); - - if (__DEV__ && !this.#state.value) { - logMissingFeature(PlayButtonElement.tagName, 'playback'); - } - } - - 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, PlayButtonDataAttrs); + protected activate(state: MediaPlaybackState): void { + this.core.toggle(state); } } diff --git a/packages/html/src/ui/playback-rate-button/playback-rate-button-element.ts b/packages/html/src/ui/playback-rate-button/playback-rate-button-element.ts index 719fefab..630a848a 100644 --- a/packages/html/src/ui/playback-rate-button/playback-rate-button-element.ts +++ b/packages/html/src/ui/playback-rate-button/playback-rate-button-element.ts @@ -1,70 +1,18 @@ -import { PlaybackRateButtonCore, PlaybackRateButtonDataAttrs } from '@videojs/core'; -import { - applyElementProps, - applyStateDataAttrs, - createButton, - logMissingFeature, - selectPlaybackRate, -} from '@videojs/core/dom'; -import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; +import { type MediaPlaybackRateState, PlaybackRateButtonCore, PlaybackRateButtonDataAttrs } from '@videojs/core'; +import { selectPlaybackRate } from '@videojs/core/dom'; import { playerContext } from '../../player/context'; import { PlayerController } from '../../player/player-controller'; -import { MediaElement } from '../media-element'; +import { MediaButtonElement } from '../media-button-element'; -export class PlaybackRateButtonElement extends MediaElement { +export class PlaybackRateButtonElement extends MediaButtonElement { static readonly tagName = 'media-playback-rate-button'; - static override properties = { - label: { type: String }, - disabled: { type: Boolean }, - } satisfies PropertyDeclarationMap; + protected readonly core = new PlaybackRateButtonCore(); + protected readonly stateAttrMap = PlaybackRateButtonDataAttrs; + protected readonly mediaState = new PlayerController(this, playerContext, selectPlaybackRate); - label = PlaybackRateButtonCore.defaultProps.label; - disabled = PlaybackRateButtonCore.defaultProps.disabled; - - readonly #core = new PlaybackRateButtonCore(); - readonly #state = new PlayerController(this, playerContext, selectPlaybackRate); - - #disconnect: AbortController | null = null; - - override connectedCallback(): void { - super.connectedCallback(); - - this.#disconnect = new AbortController(); - - const buttonProps = createButton({ - onActivate: () => this.#core.cycle(this.#state.value!), - isDisabled: () => this.disabled || !this.#state.value, - }); - - applyElementProps(this, buttonProps, this.#disconnect.signal); - - if (__DEV__ && !this.#state.value) { - logMissingFeature(PlaybackRateButtonElement.tagName, 'playbackRate'); - } - } - - 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, PlaybackRateButtonDataAttrs); + protected activate(state: MediaPlaybackRateState): void { + this.core.cycle(state); } } diff --git a/packages/html/src/ui/popover/popover-element.ts b/packages/html/src/ui/popover/popover-element.ts index 041a4041..4bf1e2b9 100644 --- a/packages/html/src/ui/popover/popover-element.ts +++ b/packages/html/src/ui/popover/popover-element.ts @@ -1,13 +1,13 @@ -import { PopoverCore, PopoverDataAttrs, type PopoverInteraction, type PopoverProps } from '@videojs/core'; +import { PopoverCore, PopoverDataAttrs, type PopoverInput, type PopoverProps } from '@videojs/core'; import { applyElementProps, applyStateDataAttrs, createPopover, - createTransitionHandler, + createTransition, getAnchorNameStyle, getAnchorPositionStyle, + type PopoverApi, type PopoverChangeDetails, - type PopoverHandle, resolveOffsets, } from '@videojs/core/dom'; import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; @@ -44,12 +44,12 @@ export class PopoverElement extends MediaElement { closeDelay = PopoverCore.defaultProps.closeDelay; readonly #core = new PopoverCore(); - #popover: PopoverHandle | null = null; - #snapshot: SnapshotController | null = null; + #popover: PopoverApi | null = null; + #snapshot: SnapshotController | null = null; // Cleanup controllers #disconnect: AbortController | null = null; - #triggerAc: AbortController | null = null; + #triggerAbort: AbortController | null = null; #currentTrigger: HTMLElement | null = null; override connectedCallback(): void { @@ -57,7 +57,7 @@ export class PopoverElement extends MediaElement { this.#disconnect = new AbortController(); this.#popover = createPopover({ - transition: createTransitionHandler(), + transition: createTransition(), onOpenChange: (nextOpen: boolean, details: PopoverChangeDetails) => { this.open = nextOpen; this.dispatchEvent(new CustomEvent('open-change', { detail: { open: nextOpen, ...details } })); @@ -73,15 +73,15 @@ export class PopoverElement extends MediaElement { this.#popover.setPopupElement(this); // Apply popup event handlers (pointerenter/leave, focusout) to self. - applyElementProps(this, this.#popover.popupProps, this.#disconnect.signal); + applyElementProps(this, this.#popover.popupProps, { signal: this.#disconnect.signal }); // Subscribe to interaction state for reactive updates. // Reuse the controller across connect/disconnect cycles to avoid // leaking stale controllers in the host's controller set. if (this.#snapshot) { - this.#snapshot.track(this.#popover.interaction); + this.#snapshot.track(this.#popover.input); } else { - this.#snapshot = new SnapshotController(this, this.#popover.interaction); + this.#snapshot = new SnapshotController(this, this.#popover.input); } } @@ -110,7 +110,7 @@ export class PopoverElement extends MediaElement { // Sync controlled open state if (this.#popover && changed.has('open')) { - const { active: interactionOpen } = this.#popover.interaction.current; + const { active: interactionOpen } = this.#popover.input.current; if (this.open !== interactionOpen) { if (this.open) { this.#popover.open(); @@ -129,9 +129,10 @@ export class PopoverElement extends MediaElement { const triggerEl = this.#findTrigger(); this.#syncTrigger(triggerEl); - // Derive state from core + interaction. - const interaction = this.#popover.interaction.current; - const state = this.#core.getState(interaction); + // Derive state from core + input. + const input = this.#popover.input.current; + this.#core.setInput(input); + const state = this.#core.getState(); // Apply popup ARIA and data attributes to self. applyElementProps(this, this.#core.getPopupAttrs(state)); @@ -178,8 +179,8 @@ export class PopoverElement extends MediaElement { this.#popover?.setTriggerElement(triggerEl); if (triggerEl && this.#popover) { - this.#triggerAc = new AbortController(); - applyElementProps(triggerEl, this.#popover.triggerProps, this.#triggerAc.signal); + this.#triggerAbort = new AbortController(); + applyElementProps(triggerEl, this.#popover.triggerProps, { signal: this.#triggerAbort.signal }); } } @@ -194,8 +195,8 @@ export class PopoverElement extends MediaElement { this.#currentTrigger.style.removeProperty('anchor-name'); } - this.#triggerAc?.abort(); - this.#triggerAc = null; + this.#triggerAbort?.abort(); + this.#triggerAbort = null; this.#currentTrigger = null; } } diff --git a/packages/html/src/ui/poster/poster-element.ts b/packages/html/src/ui/poster/poster-element.ts index 9f614aa1..5ef42c98 100644 --- a/packages/html/src/ui/poster/poster-element.ts +++ b/packages/html/src/ui/poster/poster-element.ts @@ -1,34 +1,14 @@ import { PosterCore, PosterDataAttrs } from '@videojs/core'; -import { applyStateDataAttrs, logMissingFeature, selectPlayback } from '@videojs/core/dom'; -import type { PropertyValues } from '@videojs/element'; +import { selectPlayback } from '@videojs/core/dom'; import { playerContext } from '../../player/context'; import { PlayerController } from '../../player/player-controller'; -import { MediaElement } from '../media-element'; +import { MediaUIElement } from '../media-ui-element'; -export class PosterElement extends MediaElement { +export class PosterElement extends MediaUIElement { static readonly tagName = 'media-poster'; - readonly #core = new PosterCore(); - readonly #state = new PlayerController(this, playerContext, selectPlayback); - - override connectedCallback(): void { - super.connectedCallback(); - - if (__DEV__ && !this.#state.value) { - logMissingFeature(PosterElement.tagName, 'playback'); - } - } - - protected override update(changed: PropertyValues): void { - super.update(changed); - - const media = this.#state.value; - - if (!media) { - return; - } - - applyStateDataAttrs(this, this.#core.getState(media), PosterDataAttrs); - } + protected readonly core = new PosterCore(); + protected readonly stateAttrMap = PosterDataAttrs; + protected readonly mediaState = new PlayerController(this, playerContext, selectPlayback); } diff --git a/packages/html/src/ui/seek-button/seek-button-element.ts b/packages/html/src/ui/seek-button/seek-button-element.ts index 8dda1e2b..1d3bae76 100644 --- a/packages/html/src/ui/seek-button/seek-button-element.ts +++ b/packages/html/src/ui/seek-button/seek-button-element.ts @@ -1,66 +1,26 @@ -import { SeekButtonCore, SeekButtonDataAttrs } from '@videojs/core'; -import { applyElementProps, applyStateDataAttrs, createButton, logMissingFeature, selectTime } from '@videojs/core/dom'; -import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; +import { type MediaTimeState, SeekButtonCore, SeekButtonDataAttrs } from '@videojs/core'; +import { selectTime } from '@videojs/core/dom'; +import type { PropertyDeclarationMap } from '@videojs/element'; import { playerContext } from '../../player/context'; import { PlayerController } from '../../player/player-controller'; -import { MediaElement } from '../media-element'; +import { MediaButtonElement } from '../media-button-element'; -export class SeekButtonElement extends MediaElement { +export class SeekButtonElement extends MediaButtonElement { static readonly tagName = 'media-seek-button'; - static override properties = { + static override properties: PropertyDeclarationMap = { + ...MediaButtonElement.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); + protected readonly core = new SeekButtonCore(); + protected readonly stateAttrMap = SeekButtonDataAttrs; + protected readonly mediaState = 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); + protected activate(state: MediaTimeState): void { + this.core.seek(state); } } diff --git a/packages/html/src/ui/slider/slider-context.ts b/packages/html/src/ui/slider/context.ts similarity index 84% rename from packages/html/src/ui/slider/slider-context.ts rename to packages/html/src/ui/slider/context.ts index 78287e62..2b172abe 100644 --- a/packages/html/src/ui/slider/slider-context.ts +++ b/packages/html/src/ui/slider/context.ts @@ -1,10 +1,12 @@ -import type { SliderState } from '@videojs/core'; +import type { SliderState, StateAttrMap } from '@videojs/core'; import type { SliderThumbProps } from '@videojs/core/dom'; import { createContext } from '@videojs/element/context'; export interface SliderContextValue { /** Base slider state — children use this for data attributes and value display. */ state: SliderState; + /** Maps state keys to `data-*` attribute names for part elements. */ + stateAttrMap: StateAttrMap; /** Domain value at the current pointer position (e.g., seconds for time, percent for volume). */ pointerValue: number; /** ARIA attributes for the thumb element (role, tabindex, aria-value*, etc.). */ diff --git a/packages/html/src/ui/slider/slider-buffer-element.ts b/packages/html/src/ui/slider/slider-buffer-element.ts index 11261c89..cf4e7e34 100644 --- a/packages/html/src/ui/slider/slider-buffer-element.ts +++ b/packages/html/src/ui/slider/slider-buffer-element.ts @@ -1,22 +1,11 @@ -import { SliderDataAttrs } from '@videojs/core'; -import { applyStateDataAttrs } from '@videojs/core/dom'; -import type { PropertyValues } from '@videojs/element'; +import type { SliderState } from '@videojs/core'; import { ContextConsumer } from '@videojs/element/context'; -import { MediaElement } from '../media-element'; -import { sliderContext } from './slider-context'; +import { ContextPartElement } from '../context-part-element'; +import { sliderContext } from './context'; -export class SliderBufferElement extends MediaElement { +export class SliderBufferElement extends ContextPartElement { static readonly tagName = 'media-slider-buffer'; - readonly #ctx = new ContextConsumer(this, { - context: sliderContext, - subscribe: true, - }); - - protected override update(_changed: PropertyValues): void { - super.update(_changed); - const ctx = this.#ctx.value; - if (ctx) applyStateDataAttrs(this, ctx.state, SliderDataAttrs); - } + protected readonly consumer = new ContextConsumer(this, { context: sliderContext, subscribe: true }); } diff --git a/packages/html/src/ui/slider/slider-element.ts b/packages/html/src/ui/slider/slider-element.ts index 95b947ca..01a6e60f 100644 --- a/packages/html/src/ui/slider/slider-element.ts +++ b/packages/html/src/ui/slider/slider-element.ts @@ -1,11 +1,11 @@ import { SliderCore, SliderDataAttrs } from '@videojs/core'; -import { applyStateDataAttrs, createSlider, getSliderCSSVars, type SliderHandle } from '@videojs/core/dom'; +import { applyStateDataAttrs, createSlider, getSliderCSSVars, type SliderApi } from '@videojs/core/dom'; import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; import { ContextProvider } from '@videojs/element/context'; import { applyStyles, isRTL } from '@videojs/utils/dom'; import { MediaElement } from '../media-element'; -import { sliderContext } from './slider-context'; +import { sliderContext } from './context'; export class SliderElement extends MediaElement { static readonly tagName = 'media-slider'; @@ -35,7 +35,7 @@ export class SliderElement extends MediaElement { readonly #core = new SliderCore(); readonly #provider = new ContextProvider(this, { context: sliderContext }); - #slider: SliderHandle | null = null; + #slider: SliderApi | null = null; #disconnect: AbortController | null = null; override connectedCallback(): void { @@ -51,16 +51,8 @@ export class SliderElement extends MediaElement { isRTL: () => isRTL(this), isDisabled: () => this.disabled, getPercent: () => this.#core.percentFromValue(this.value), - getStepPercent: () => { - const { step, min, max } = this.#core.props; - const range = max - min; - return range > 0 ? (step / range) * 100 : 0; - }, - getLargeStepPercent: () => { - const { largeStep, min, max } = this.#core.props; - const range = max - min; - return range > 0 ? (largeStep / range) * 100 : 0; - }, + getStepPercent: () => this.#core.getStepPercent(), + getLargeStepPercent: () => this.#core.getLargeStepPercent(), onValueChange: (percent) => { this.value = this.#core.valueFromPercent(percent); this.dispatchEvent(new CustomEvent('value-change', { detail: { value: this.value }, bubbles: true })); @@ -77,7 +69,7 @@ export class SliderElement extends MediaElement { }, }); - this.#slider.interaction.subscribe(() => this.requestUpdate(), { signal }); + this.#slider.input.subscribe(() => this.requestUpdate(), { signal }); // Prevent default touch gestures and text selection during interaction. this.style.touchAction = 'none'; @@ -101,8 +93,8 @@ export class SliderElement extends MediaElement { super.update(_changed); if (!this.#slider) return; - const interaction = this.#slider.interaction.current; - const state = this.#core.getState(interaction, this.value); + this.#core.setInput(this.#slider.input.current); + const state = this.#core.getSliderState(this.value); const cssVars = getSliderCSSVars(state); applyStyles(this, cssVars); @@ -113,6 +105,7 @@ export class SliderElement extends MediaElement { // Provide context to child elements (thumb, value, track, etc.). this.#provider.setValue({ state, + stateAttrMap: SliderDataAttrs, pointerValue: this.#core.valueFromPercent(state.pointerPercent), thumbAttrs: this.#core.getAttrs(state), thumbProps: this.#slider.thumbProps, diff --git a/packages/html/src/ui/slider/slider-fill-element.ts b/packages/html/src/ui/slider/slider-fill-element.ts index 42c7d0e7..ca1aa6c6 100644 --- a/packages/html/src/ui/slider/slider-fill-element.ts +++ b/packages/html/src/ui/slider/slider-fill-element.ts @@ -1,22 +1,11 @@ -import { SliderDataAttrs } from '@videojs/core'; -import { applyStateDataAttrs } from '@videojs/core/dom'; -import type { PropertyValues } from '@videojs/element'; +import type { SliderState } from '@videojs/core'; import { ContextConsumer } from '@videojs/element/context'; -import { MediaElement } from '../media-element'; -import { sliderContext } from './slider-context'; +import { ContextPartElement } from '../context-part-element'; +import { sliderContext } from './context'; -export class SliderFillElement extends MediaElement { +export class SliderFillElement extends ContextPartElement { static readonly tagName = 'media-slider-fill'; - readonly #ctx = new ContextConsumer(this, { - context: sliderContext, - subscribe: true, - }); - - protected override update(_changed: PropertyValues): void { - super.update(_changed); - const ctx = this.#ctx.value; - if (ctx) applyStateDataAttrs(this, ctx.state, SliderDataAttrs); - } + protected readonly consumer = new ContextConsumer(this, { context: sliderContext, subscribe: true }); } diff --git a/packages/html/src/ui/slider/slider-thumb-element.ts b/packages/html/src/ui/slider/slider-thumb-element.ts index e7edc7d0..71fba355 100644 --- a/packages/html/src/ui/slider/slider-thumb-element.ts +++ b/packages/html/src/ui/slider/slider-thumb-element.ts @@ -1,10 +1,9 @@ -import { SliderDataAttrs } from '@videojs/core'; import { applyElementProps, applyStateDataAttrs } from '@videojs/core/dom'; import type { PropertyValues } from '@videojs/element'; import { ContextConsumer } from '@videojs/element/context'; import { MediaElement } from '../media-element'; -import { sliderContext } from './slider-context'; +import { sliderContext } from './context'; export class SliderThumbElement extends MediaElement { static readonly tagName = 'media-slider-thumb'; @@ -38,7 +37,7 @@ export class SliderThumbElement extends MediaElement { // Apply keyboard and focus handlers once — they don't change per slider instance. if (!this.#thumbPropsApplied && this.#disconnect) { - applyElementProps(this, ctx.thumbProps, this.#disconnect.signal); + applyElementProps(this, ctx.thumbProps, { signal: this.#disconnect.signal }); this.#thumbPropsApplied = true; } @@ -46,6 +45,6 @@ export class SliderThumbElement extends MediaElement { applyElementProps(this, ctx.thumbAttrs); // Apply state data attributes. - applyStateDataAttrs(this, ctx.state, SliderDataAttrs); + applyStateDataAttrs(this, ctx.state, ctx.stateAttrMap); } } diff --git a/packages/html/src/ui/slider/slider-track-element.ts b/packages/html/src/ui/slider/slider-track-element.ts index a8181199..4c2e19ef 100644 --- a/packages/html/src/ui/slider/slider-track-element.ts +++ b/packages/html/src/ui/slider/slider-track-element.ts @@ -1,22 +1,11 @@ -import { SliderDataAttrs } from '@videojs/core'; -import { applyStateDataAttrs } from '@videojs/core/dom'; -import type { PropertyValues } from '@videojs/element'; +import type { SliderState } from '@videojs/core'; import { ContextConsumer } from '@videojs/element/context'; -import { MediaElement } from '../media-element'; -import { sliderContext } from './slider-context'; +import { ContextPartElement } from '../context-part-element'; +import { sliderContext } from './context'; -export class SliderTrackElement extends MediaElement { +export class SliderTrackElement extends ContextPartElement { static readonly tagName = 'media-slider-track'; - readonly #ctx = new ContextConsumer(this, { - context: sliderContext, - subscribe: true, - }); - - protected override update(_changed: PropertyValues): void { - super.update(_changed); - const ctx = this.#ctx.value; - if (ctx) applyStateDataAttrs(this, ctx.state, SliderDataAttrs); - } + protected readonly consumer = new ContextConsumer(this, { context: sliderContext, subscribe: true }); } diff --git a/packages/html/src/ui/slider/slider-value-element.ts b/packages/html/src/ui/slider/slider-value-element.ts index 4f424079..c6b3b504 100644 --- a/packages/html/src/ui/slider/slider-value-element.ts +++ b/packages/html/src/ui/slider/slider-value-element.ts @@ -1,10 +1,9 @@ -import { SliderDataAttrs } from '@videojs/core'; import { applyStateDataAttrs } from '@videojs/core/dom'; import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; import { ContextConsumer } from '@videojs/element/context'; import { MediaElement } from '../media-element'; -import { sliderContext } from './slider-context'; +import { sliderContext } from './context'; export class SliderValueElement extends MediaElement { static readonly tagName = 'media-slider-value'; @@ -35,6 +34,6 @@ export class SliderValueElement extends MediaElement { this.textContent = ctx.formatValue ? ctx.formatValue(value, this.type) : String(Math.round(value)); - applyStateDataAttrs(this, ctx.state, SliderDataAttrs); + applyStateDataAttrs(this, ctx.state, ctx.stateAttrMap); } } diff --git a/packages/html/src/ui/thumbnail/thumbnail-element.ts b/packages/html/src/ui/thumbnail/thumbnail-element.ts index 4b7b0dd2..58ae6cf8 100644 --- a/packages/html/src/ui/thumbnail/thumbnail-element.ts +++ b/packages/html/src/ui/thumbnail/thumbnail-element.ts @@ -6,7 +6,7 @@ import { type ThumbnailImage, type ThumbnailResizeResult, } from '@videojs/core'; -import type { ThumbnailHandle } from '@videojs/core/dom'; +import type { ThumbnailApi } from '@videojs/core/dom'; import { applyElementProps, applyStateDataAttrs, createThumbnail, selectTextTrack } from '@videojs/core/dom'; import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; @@ -45,7 +45,7 @@ export class ThumbnailElement extends MediaElement { #thumbnails: ThumbnailImage[] = []; #externalThumbnails: ThumbnailImage[] | undefined; #lastTextTrack: MediaTextTrackState | undefined; - #handle: ThumbnailHandle | null = null; + #api: ThumbnailApi | null = null; constructor() { super(); @@ -79,7 +79,7 @@ export class ThumbnailElement extends MediaElement { override connectedCallback(): void { super.connectedCallback(); - this.#handle = createThumbnail({ + this.#api = createThumbnail({ getContainer: () => this, getImg: () => this.#img, onStateChange: () => this.requestUpdate(), @@ -88,8 +88,8 @@ export class ThumbnailElement extends MediaElement { override disconnectedCallback(): void { super.disconnectedCallback(); - this.#handle?.destroy(); - this.#handle = null; + this.#api?.destroy(); + this.#api = null; } protected override update(changed: PropertyValues): void { @@ -119,8 +119,8 @@ export class ThumbnailElement extends MediaElement { fetchpriority: this.fetchPriority, }); - // Track src changes via the handle. - this.#handle?.updateSrc(thumbnail?.url); + // Track src changes via the thumbnail API. + this.#api?.updateSrc(thumbnail?.url); if (!thumbnail) { this.#img.removeAttribute('src'); @@ -137,14 +137,14 @@ export class ThumbnailElement extends MediaElement { this.#img.src = thumbnail.url; } - const handle = this.#handle; - const state = this.#core.getState(handle?.loading ?? false, handle?.error ?? false, thumbnail); + const api = this.#api; + const state = this.#core.getState(api?.loading ?? false, api?.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 (api?.naturalWidth && api.naturalHeight) { + const constraints = api.readConstraints(); + const result = this.#core.resize(thumbnail, api.naturalWidth, api.naturalHeight, constraints); if (result) { this.#applyResize(result); diff --git a/packages/html/src/ui/time-slider/time-slider-element.ts b/packages/html/src/ui/time-slider/time-slider-element.ts index 8e618b29..41c86155 100644 --- a/packages/html/src/ui/time-slider/time-slider-element.ts +++ b/packages/html/src/ui/time-slider/time-slider-element.ts @@ -4,18 +4,19 @@ import { createSlider, getTimeSliderCSSVars, logMissingFeature, - type SliderHandle, + type SliderApi, selectBuffer, selectTime, } from '@videojs/core/dom'; import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; import { ContextProvider } from '@videojs/element/context'; import { applyStyles, isRTL } from '@videojs/utils/dom'; +import { formatTime } from '@videojs/utils/time'; import { playerContext } from '../../player/context'; import { PlayerController } from '../../player/player-controller'; import { MediaElement } from '../media-element'; -import { sliderContext } from '../slider/slider-context'; +import { sliderContext } from '../slider/context'; export class TimeSliderElement extends MediaElement { static readonly tagName = 'media-time-slider'; @@ -28,7 +29,7 @@ export class TimeSliderElement extends MediaElement { orientation: { type: String }, disabled: { type: Boolean }, thumbAlignment: { type: String, attribute: 'thumb-alignment' }, - } satisfies PropertyDeclarationMap; + } satisfies PropertyDeclarationMap>; label = TimeSliderCore.defaultProps.label; commitThrottle = TimeSliderCore.defaultProps.commitThrottle; @@ -43,7 +44,7 @@ export class TimeSliderElement extends MediaElement { readonly #timeState = new PlayerController(this, playerContext, selectTime); readonly #bufferState = new PlayerController(this, playerContext, selectBuffer); - #slider: SliderHandle | null = null; + #slider: SliderApi | null = null; #disconnect: AbortController | null = null; override connectedCallback(): void { @@ -63,21 +64,11 @@ export class TimeSliderElement extends MediaElement { if (!media) return 0; return this.#core.percentFromValue(media.currentTime); }, - getStepPercent: () => { - const { step, min, max } = this.#core.props; - const range = max - min; - return range > 0 ? (step / range) * 100 : 0; - }, - getLargeStepPercent: () => { - const { largeStep, min, max } = this.#core.props; - const range = max - min; - return range > 0 ? (largeStep / range) * 100 : 0; - }, - onValueChange: () => { - // Visual update only — CSS vars are refreshed in update(). - }, + getStepPercent: () => this.#core.getStepPercent(), + getLargeStepPercent: () => this.#core.getLargeStepPercent(), onValueCommit: (percent) => { - this.#seek(percent); + const media = this.#timeState.value; + if (media) media.seek(this.#core.valueFromPercent(percent)); }, commitThrottle: this.commitThrottle, onDragStart: () => { @@ -88,14 +79,14 @@ export class TimeSliderElement extends MediaElement { }, }); - this.#slider.interaction.subscribe(() => this.requestUpdate(), { signal }); + this.#slider.input.subscribe(() => this.requestUpdate(), { signal }); // Prevent default touch gestures and text selection during interaction. this.style.touchAction = 'none'; this.style.userSelect = 'none'; if (__DEV__ && !this.#timeState.value) { - logMissingFeature(TimeSliderElement.tagName, 'time'); + logMissingFeature(this.localName, this.#timeState.displayName!); } } @@ -120,9 +111,10 @@ export class TimeSliderElement extends MediaElement { const buffer = this.#bufferState.value; if (!time) return; - const interaction = this.#slider.interaction.current; + this.#core.setInput(this.#slider.input.current); const media = { ...time, ...(buffer ?? { buffered: [], seekable: [] }) }; - const state = this.#core.getTimeState(media, interaction); + this.#core.setMedia(media); + const state = this.#core.getState(); const cssVars = getTimeSliderCSSVars(state); applyStyles(this, cssVars); @@ -133,24 +125,11 @@ export class TimeSliderElement extends MediaElement { // Provide context to child elements with base slider data attrs. this.#provider.setValue({ state, + stateAttrMap: TimeSliderDataAttrs, pointerValue: this.#core.valueFromPercent(state.pointerPercent), thumbAttrs: this.#core.getAttrs(state), thumbProps: this.#slider.thumbProps, - formatValue: (value) => formatTime(value), + formatValue: (value) => formatTime(value, state.duration), }); } - - #seek(percent: number): void { - const media = this.#timeState.value; - if (!media) return; - const time = this.#core.valueFromPercent(percent); - media.seek(time); - } -} - -function formatTime(seconds: number): string { - const total = Math.round(seconds); - const minutes = Math.floor(total / 60); - const secs = total % 60; - return `${minutes}:${secs.toString().padStart(2, '0')}`; } diff --git a/packages/html/src/ui/time/time-element.ts b/packages/html/src/ui/time/time-element.ts index 76bc23c9..73af63db 100644 --- a/packages/html/src/ui/time/time-element.ts +++ b/packages/html/src/ui/time/time-element.ts @@ -39,7 +39,7 @@ export class TimeElement extends MediaElement { super.connectedCallback(); if (__DEV__ && !this.#state.value) { - logMissingFeature(TimeElement.tagName, 'time'); + logMissingFeature(this.localName, this.#state.displayName!); } } @@ -55,7 +55,8 @@ export class TimeElement extends MediaElement { if (!media) return; - const state = this.#core.getState(media); + this.#core.setMedia(media); + const state = this.#core.getState(); this.#signSpan.hidden = !state.negative; this.#signSpan.textContent = state.negative ? this.negativeSign : ''; diff --git a/packages/html/src/ui/volume-slider/volume-slider-element.ts b/packages/html/src/ui/volume-slider/volume-slider-element.ts index 121238f7..c5aae4f6 100644 --- a/packages/html/src/ui/volume-slider/volume-slider-element.ts +++ b/packages/html/src/ui/volume-slider/volume-slider-element.ts @@ -4,7 +4,7 @@ import { createSlider, getSliderCSSVars, logMissingFeature, - type SliderHandle, + type SliderApi, selectVolume, } from '@videojs/core/dom'; import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; @@ -14,7 +14,7 @@ import { applyStyles, isRTL } from '@videojs/utils/dom'; import { playerContext } from '../../player/context'; import { PlayerController } from '../../player/player-controller'; import { MediaElement } from '../media-element'; -import { sliderContext } from '../slider/slider-context'; +import { sliderContext } from '../slider/context'; export class VolumeSliderElement extends MediaElement { static readonly tagName = 'media-volume-slider'; @@ -26,7 +26,7 @@ export class VolumeSliderElement extends MediaElement { orientation: { type: String }, disabled: { type: Boolean }, thumbAlignment: { type: String, attribute: 'thumb-alignment' }, - } satisfies PropertyDeclarationMap; + } satisfies PropertyDeclarationMap>; label = VolumeSliderCore.defaultProps.label; step = VolumeSliderCore.defaultProps.step; @@ -39,7 +39,7 @@ export class VolumeSliderElement extends MediaElement { readonly #provider = new ContextProvider(this, { context: sliderContext }); readonly #volumeState = new PlayerController(this, playerContext, selectVolume); - #slider: SliderHandle | null = null; + #slider: SliderApi | null = null; #disconnect: AbortController | null = null; override connectedCallback(): void { @@ -59,16 +59,8 @@ export class VolumeSliderElement extends MediaElement { if (!media) return 0; return media.volume * 100; }, - getStepPercent: () => { - const { step, min, max } = this.#core.props; - const range = max - min; - return range > 0 ? (step / range) * 100 : 0; - }, - getLargeStepPercent: () => { - const { largeStep, min, max } = this.#core.props; - const range = max - min; - return range > 0 ? (largeStep / range) * 100 : 0; - }, + getStepPercent: () => this.#core.getStepPercent(), + getLargeStepPercent: () => this.#core.getLargeStepPercent(), onValueChange: (percent) => { this.#setVolume(percent); }, @@ -83,14 +75,14 @@ export class VolumeSliderElement extends MediaElement { }, }); - this.#slider.interaction.subscribe(() => this.requestUpdate(), { signal }); + this.#slider.input.subscribe(() => this.requestUpdate(), { signal }); // Prevent default touch gestures and text selection during interaction. this.style.touchAction = 'none'; this.style.userSelect = 'none'; if (__DEV__ && !this.#volumeState.value) { - logMissingFeature(VolumeSliderElement.tagName, 'volume'); + logMissingFeature(this.localName, this.#volumeState.displayName!); } } @@ -114,8 +106,9 @@ export class VolumeSliderElement extends MediaElement { const media = this.#volumeState.value; if (!media) return; - const interaction = this.#slider.interaction.current; - const state = this.#core.getVolumeState(media, interaction); + this.#core.setInput(this.#slider.input.current); + this.#core.setMedia(media); + const state = this.#core.getState(); const cssVars = getSliderCSSVars(state); applyStyles(this, cssVars); @@ -126,6 +119,7 @@ export class VolumeSliderElement extends MediaElement { // Provide context to child elements. this.#provider.setValue({ state, + stateAttrMap: SliderDataAttrs, pointerValue: this.#core.valueFromPercent(state.pointerPercent), thumbAttrs: this.#core.getAttrs(state), thumbProps: this.#slider.thumbProps, diff --git a/packages/react/src/ui/controls/context.tsx b/packages/react/src/ui/controls/context.tsx new file mode 100644 index 00000000..1e8dd244 --- /dev/null +++ b/packages/react/src/ui/controls/context.tsx @@ -0,0 +1,19 @@ +'use client'; + +import type { ControlsState, StateAttrMap } from '@videojs/core'; +import { createContext, useContext } from 'react'; + +export interface ControlsContextValue { + state: ControlsState; + stateAttrMap: StateAttrMap; +} + +const ControlsContext = createContext(null); + +export const ControlsContextProvider = ControlsContext.Provider; + +export function useControlsContext(): ControlsContextValue { + const ctx = useContext(ControlsContext); + if (!ctx) throw new Error('Controls compound components must be used within a Controls.Root'); + return ctx; +} diff --git a/packages/react/src/ui/controls/controls-group.tsx b/packages/react/src/ui/controls/controls-group.tsx index 1f3e3e29..8c19568e 100644 --- a/packages/react/src/ui/controls/controls-group.tsx +++ b/packages/react/src/ui/controls/controls-group.tsx @@ -1,14 +1,14 @@ 'use client'; +import type { ControlsCore } from '@videojs/core'; import type { ForwardedRef, ReactNode } from 'react'; import { forwardRef } from 'react'; import type { UIComponentProps } from '../../utils/types'; import { renderElement } from '../../utils/use-render'; +import { useControlsContext } from './context'; -type GroupState = Record; - -export interface ControlsGroupProps extends UIComponentProps<'div', GroupState> { +export interface ControlsGroupProps extends UIComponentProps<'div', ControlsCore.State> { children?: ReactNode | undefined; } @@ -19,15 +19,15 @@ export const ControlsGroup = forwardRef(function ControlsGroup( ) { const { render, className, style, children, ...elementProps } = componentProps; + const { state, stateAttrMap } = useControlsContext(); const role = elementProps['aria-label'] || elementProps['aria-labelledby'] ? 'group' : undefined; - const state: GroupState = {}; - return renderElement( 'div', { render, className, style }, { state, + stateAttrMap, ref: [forwardedRef], props: [{ role, children }, elementProps], } diff --git a/packages/react/src/ui/controls/controls-root.tsx b/packages/react/src/ui/controls/controls-root.tsx index 0c764b97..632f4d97 100644 --- a/packages/react/src/ui/controls/controls-root.tsx +++ b/packages/react/src/ui/controls/controls-root.tsx @@ -8,6 +8,7 @@ import { forwardRef, useState } from 'react'; import { usePlayer } from '../../player/context'; import type { UIComponentProps } from '../../utils/types'; import { renderElement } from '../../utils/use-render'; +import { ControlsContextProvider } from './context'; export interface ControlsRootProps extends UIComponentProps<'div', ControlsCore.State> { children?: ReactNode | undefined; @@ -29,17 +30,22 @@ export const ControlsRoot = forwardRef(function ControlsRoot( return null; } - const state = core.getState(controls); + core.setMedia(controls); + const state = core.getState(); - return renderElement( - 'div', - { render, className, style }, - { - state, - stateAttrMap: ControlsDataAttrs, - ref: [forwardedRef], - props: [{ children }, elementProps], - } + return ( + + {renderElement( + 'div', + { render, className, style }, + { + state, + stateAttrMap: ControlsDataAttrs, + ref: [forwardedRef], + props: [{ children }, elementProps], + } + )} + ); }); diff --git a/packages/react/src/ui/create-context-part.tsx b/packages/react/src/ui/create-context-part.tsx new file mode 100644 index 00000000..f4bfbfff --- /dev/null +++ b/packages/react/src/ui/create-context-part.tsx @@ -0,0 +1,37 @@ +'use client'; + +import type { StateAttrMap } from '@videojs/core'; +import type { ForwardRefExoticComponent } from 'react'; +import { forwardRef } from 'react'; +import type { UIComponentProps } from '@/utils/types'; +import type { renderElement as renderElementFn } from '../utils/use-render'; +import { renderElement } from '../utils/use-render'; + +interface ContextPartConfig { + displayName: string; + tag: keyof React.JSX.IntrinsicElements; + useContext: () => { state: State; stateAttrMap: StateAttrMap }; + staticProps?: Partial; +} + +export function createContextPart, State extends object>( + config: ContextPartConfig +): ForwardRefExoticComponent { + const { displayName, tag, useContext, staticProps } = config; + + const Component = forwardRef(function ContextPart(componentProps, forwardedRef) { + const { render, className, style, ...elementProps } = componentProps; + const context = useContext(); + + return renderElement(tag, { render, className, style } as renderElementFn.ComponentProps, { + state: context.state, + stateAttrMap: context.stateAttrMap, + ref: forwardedRef, + props: staticProps ? [staticProps, elementProps] : [elementProps], + }); + }); + + Component.displayName = displayName; + + return Component as ForwardRefExoticComponent; +} diff --git a/packages/react/src/ui/create-media-button.tsx b/packages/react/src/ui/create-media-button.tsx new file mode 100644 index 00000000..0f54554f --- /dev/null +++ b/packages/react/src/ui/create-media-button.tsx @@ -0,0 +1,84 @@ +'use client'; + +import type { InferComponentState, InferMediaState, MediaUIComponent, StateAttrMap } from '@videojs/core'; +import { logMissingFeature } from '@videojs/core/dom'; +import type { Selector } from '@videojs/store'; +import type { ForwardedRef, ForwardRefExoticComponent, RefAttributes } from 'react'; +import { forwardRef, useState } from 'react'; + +import { usePlayer } from '../player/context'; +import type { renderElement as renderElementFn } from '../utils/use-render'; +import { renderElement } from '../utils/use-render'; +import { useButton } from './hooks/use-button'; + +interface MediaButtonConfig> { + displayName: string; + core: { new (): Core; defaultProps: Record }; + stateAttrMap: StateAttrMap>; + selector: Selector | undefined>; + action: (core: Core, state: InferMediaState) => void; +} + +/** Creates a media button React component from a core class and config. */ +export function createMediaButton, Props extends object>( + config: MediaButtonConfig +): ForwardRefExoticComponent> { + const { displayName, core: CoreClass, stateAttrMap, selector, action } = config; + + // Props that exist in the core's defaultProps are routed to setProps; the rest go to the DOM element. + const corePropKeys = new Set(Object.keys(CoreClass.defaultProps)); + + const Component = forwardRef(function MediaButton( + componentProps: Record, + forwardedRef: ForwardedRef + ) { + const { render, className, style, ...rest } = componentProps; + + const coreProps: Record = {}; + const elementProps: Record = {}; + + for (const key of Object.keys(rest)) { + if (corePropKeys.has(key)) { + coreProps[key] = rest[key]; + } else { + elementProps[key] = rest[key]; + } + } + + const feature = usePlayer(selector); + + const [core] = useState(() => new CoreClass()); + core.setProps(coreProps); + + const { getButtonProps, buttonRef } = useButton({ + displayName, + onActivate: () => action(core, feature!), + isDisabled: () => !!coreProps.disabled || !feature, + }); + + if (!feature) { + if (__DEV__) logMissingFeature(displayName, selector.displayName ?? displayName); + return null; + } + + type State = InferComponentState; + + core.setMedia(feature); + const state = core.getState() as State; + + return renderElement( + 'button', + { render, className, style } as renderElementFn.ComponentProps>, + { + state, + stateAttrMap, + ref: [forwardedRef, buttonRef], + props: [core.getAttrs(state), elementProps, getButtonProps()], + } + ); + }); + + Component.displayName = displayName; + + return Component as unknown as ForwardRefExoticComponent>; +} diff --git a/packages/react/src/ui/fullscreen-button/fullscreen-button.tsx b/packages/react/src/ui/fullscreen-button/fullscreen-button.tsx index dba3c7d7..dcc30713 100644 --- a/packages/react/src/ui/fullscreen-button/fullscreen-button.tsx +++ b/packages/react/src/ui/fullscreen-button/fullscreen-button.tsx @@ -1,53 +1,22 @@ 'use client'; import { FullscreenButtonCore, FullscreenButtonDataAttrs } from '@videojs/core'; -import { logMissingFeature, selectFullscreen } from '@videojs/core/dom'; -import type { ForwardedRef } from 'react'; -import { forwardRef, useState } from 'react'; +import { selectFullscreen } from '@videojs/core/dom'; -import { usePlayer } from '../../player/context'; import type { UIComponentProps } from '../../utils/types'; -import { renderElement } from '../../utils/use-render'; -import { useButton } from '../hooks/use-button'; +import { createMediaButton } from '../create-media-button'; export interface FullscreenButtonProps extends UIComponentProps<'button', FullscreenButtonCore.State>, FullscreenButtonCore.Props {} -export const FullscreenButton = forwardRef(function FullscreenButton( - componentProps: FullscreenButtonProps, - forwardedRef: ForwardedRef -) { - const { render, className, style, label, disabled, ...elementProps } = componentProps; - - const fullscreen = usePlayer(selectFullscreen); - - const [core] = useState(() => new FullscreenButtonCore()); - core.setProps({ label, disabled }); - - const { getButtonProps, buttonRef } = useButton({ - displayName: 'FullscreenButton', - onActivate: () => core.toggle(fullscreen!), - isDisabled: () => disabled || !fullscreen, - }); - - if (!fullscreen) { - if (__DEV__) logMissingFeature('FullscreenButton', 'fullscreen'); - return null; - } - - const state = core.getState(fullscreen); - - return renderElement( - 'button', - { render, className, style }, - { - state, - stateAttrMap: FullscreenButtonDataAttrs, - ref: [forwardedRef, buttonRef], - props: [core.getAttrs(state), elementProps, getButtonProps()], - } - ); +/** A button that toggles fullscreen. */ +export const FullscreenButton = createMediaButton({ + displayName: 'FullscreenButton', + core: FullscreenButtonCore, + stateAttrMap: FullscreenButtonDataAttrs, + selector: selectFullscreen, + action: (core, state) => core.toggle(state), }); export namespace FullscreenButton { diff --git a/packages/react/src/ui/hooks/use-slider.ts b/packages/react/src/ui/hooks/use-slider.ts index 81835fdd..80cef296 100644 --- a/packages/react/src/ui/hooks/use-slider.ts +++ b/packages/react/src/ui/hooks/use-slider.ts @@ -1,9 +1,9 @@ 'use client'; -import type { SliderInteraction, SliderState } from '@videojs/core'; +import type { SliderInput, SliderState } from '@videojs/core'; import { createSlider, - type SliderHandle, + type SliderApi, type SliderOptions, type SliderRootProps, type SliderThumbProps, @@ -26,7 +26,7 @@ export interface UseSliderOptions | 'onDragStart' | 'onDragEnd' > { - computeState: (interaction: SliderInteraction) => State; + computeState: (input: SliderInput) => State; orientation?: 'horizontal' | 'vertical' | undefined; disabled?: boolean | undefined; /** Adjust a raw 0–100 percent for thumb alignment. Called for fill and pointer percents. */ @@ -45,10 +45,10 @@ export interface UseSliderReturnValue { } /** - * Manages slider interaction lifecycle for React. + * Manages slider input lifecycle for React. * * Wraps `createSlider()` from `@videojs/core/dom` and subscribes to its - * interaction state via `useSnapshot`. Returns split props for the root + * input state via `useSnapshot`. Returns split props for the root * (pointer events) and thumb (keyboard/focus) elements. */ export function useSlider( @@ -61,7 +61,7 @@ export function useSlider( const forceRender = useForceRender(); // Lazy-init the slider handle. Stable across re-renders. - const [slider] = useState(() => { + const [slider] = useState(() => { const stableOptions: SliderOptions = { getElement: () => rootElementRef.current!, getThumbElement: () => thumbElementRef.current, @@ -84,11 +84,11 @@ export function useSlider( // Cleanup on unmount. useEffect(() => () => slider.destroy(), [slider]); - // Subscribe to interaction state. - const interaction = useSnapshot(slider.interaction); + // Subscribe to slider input state. + const input = useSnapshot(slider.input); - // Compute derived state from interaction + caller-provided projection. - const state = options.computeState(interaction); + // Compute derived state from input + caller-provided projection. + const state = options.computeState(input); // Force a synchronous re-render after mount so edge thumb alignment // can read DOM measurements from the now-populated element refs. diff --git a/packages/react/src/ui/mute-button/mute-button.tsx b/packages/react/src/ui/mute-button/mute-button.tsx index fea060f2..fc2ff044 100644 --- a/packages/react/src/ui/mute-button/mute-button.tsx +++ b/packages/react/src/ui/mute-button/mute-button.tsx @@ -1,54 +1,20 @@ 'use client'; import { MuteButtonCore, MuteButtonDataAttrs } from '@videojs/core'; -import { logMissingFeature, selectVolume } from '@videojs/core/dom'; -import type { ForwardedRef } from 'react'; -import { forwardRef, useState } from 'react'; +import { selectVolume } from '@videojs/core/dom'; -import { usePlayer } from '../../player/context'; import type { UIComponentProps } from '../../utils/types'; -import { renderElement } from '../../utils/use-render'; -import { useButton } from '../hooks/use-button'; +import { createMediaButton } from '../create-media-button'; export interface MuteButtonProps extends UIComponentProps<'button', MuteButtonCore.State>, MuteButtonCore.Props {} -/** - * A button that toggles mute state. - */ -export const MuteButton = forwardRef(function MuteButton( - componentProps: MuteButtonProps, - forwardedRef: ForwardedRef -) { - const { render, className, style, label, disabled, ...elementProps } = componentProps; - - const volume = usePlayer(selectVolume); - - const [core] = useState(() => new MuteButtonCore()); - core.setProps({ label, disabled }); - - const { getButtonProps, buttonRef } = useButton({ - displayName: 'MuteButton', - onActivate: () => core.toggle(volume!), - isDisabled: () => disabled || !volume, - }); - - if (!volume) { - if (__DEV__) logMissingFeature('MuteButton', 'volume'); - return null; - } - - const state = core.getState(volume); - - return renderElement( - 'button', - { render, className, style }, - { - state, - stateAttrMap: MuteButtonDataAttrs, - ref: [forwardedRef, buttonRef], - props: [core.getAttrs(state), elementProps, getButtonProps()], - } - ); +/** A button that toggles mute state. */ +export const MuteButton = createMediaButton({ + displayName: 'MuteButton', + core: MuteButtonCore, + stateAttrMap: MuteButtonDataAttrs, + selector: selectVolume, + action: (core, state) => core.toggle(state), }); export namespace MuteButton { diff --git a/packages/react/src/ui/pip-button/pip-button.tsx b/packages/react/src/ui/pip-button/pip-button.tsx index d9d6464e..6d3966b1 100644 --- a/packages/react/src/ui/pip-button/pip-button.tsx +++ b/packages/react/src/ui/pip-button/pip-button.tsx @@ -1,51 +1,20 @@ 'use client'; import { PiPButtonCore, PiPButtonDataAttrs } from '@videojs/core'; -import { logMissingFeature, selectPiP } from '@videojs/core/dom'; -import type { ForwardedRef } from 'react'; -import { forwardRef, useState } from 'react'; +import { selectPiP } from '@videojs/core/dom'; -import { usePlayer } from '../../player/context'; import type { UIComponentProps } from '../../utils/types'; -import { renderElement } from '../../utils/use-render'; -import { useButton } from '../hooks/use-button'; +import { createMediaButton } from '../create-media-button'; export interface PiPButtonProps extends UIComponentProps<'button', PiPButtonCore.State>, PiPButtonCore.Props {} -export const PiPButton = forwardRef(function PiPButton( - componentProps: PiPButtonProps, - forwardedRef: ForwardedRef -) { - const { render, className, style, label, disabled, ...elementProps } = componentProps; - - const pip = usePlayer(selectPiP); - - const [core] = useState(() => new PiPButtonCore()); - core.setProps({ label, disabled }); - - const { getButtonProps, buttonRef } = useButton({ - displayName: 'PiPButton', - onActivate: () => core.toggle(pip!), - isDisabled: () => disabled || !pip, - }); - - if (!pip) { - if (__DEV__) logMissingFeature('PiPButton', 'pip'); - return null; - } - - const state = core.getState(pip); - - return renderElement( - 'button', - { render, className, style }, - { - state, - stateAttrMap: PiPButtonDataAttrs, - ref: [forwardedRef, buttonRef], - props: [core.getAttrs(state), elementProps, getButtonProps()], - } - ); +/** A button that toggles picture-in-picture. */ +export const PiPButton = createMediaButton({ + displayName: 'PiPButton', + core: PiPButtonCore, + stateAttrMap: PiPButtonDataAttrs, + selector: selectPiP, + action: (core, state) => core.toggle(state), }); export namespace PiPButton { diff --git a/packages/react/src/ui/play-button/play-button.tsx b/packages/react/src/ui/play-button/play-button.tsx index 395485ea..80616882 100644 --- a/packages/react/src/ui/play-button/play-button.tsx +++ b/packages/react/src/ui/play-button/play-button.tsx @@ -1,14 +1,10 @@ 'use client'; import { PlayButtonCore, PlayButtonDataAttrs } from '@videojs/core'; -import { logMissingFeature, selectPlayback } from '@videojs/core/dom'; -import type { ForwardedRef } from 'react'; -import { forwardRef, useState } from 'react'; +import { selectPlayback } from '@videojs/core/dom'; -import { usePlayer } from '../../player/context'; import type { UIComponentProps } from '../../utils/types'; -import { renderElement } from '../../utils/use-render'; -import { useButton } from '../hooks/use-button'; +import { createMediaButton } from '../create-media-button'; export interface PlayButtonProps extends UIComponentProps<'button', PlayButtonCore.State>, PlayButtonCore.Props {} @@ -28,40 +24,12 @@ export interface PlayButtonProps extends UIComponentProps<'button', PlayButtonCo * /> * ``` */ -export const PlayButton = forwardRef(function PlayButton( - componentProps: PlayButtonProps, - forwardedRef: ForwardedRef -) { - const { render, className, style, label, disabled, ...elementProps } = componentProps; - - const playback = usePlayer(selectPlayback); - - const [core] = useState(() => new PlayButtonCore()); - core.setProps({ label, disabled }); - - const { getButtonProps, buttonRef } = useButton({ - displayName: 'PlayButton', - onActivate: () => core.toggle(playback!), - isDisabled: () => disabled || !playback, - }); - - if (!playback) { - if (__DEV__) logMissingFeature('PlayButton', 'playback'); - return null; - } - - const state = core.getState(playback); - - return renderElement( - 'button', - { render, className, style }, - { - state, - stateAttrMap: PlayButtonDataAttrs, - ref: [forwardedRef, buttonRef], - props: [core.getAttrs(state), elementProps, getButtonProps()], - } - ); +export const PlayButton = createMediaButton({ + displayName: 'PlayButton', + core: PlayButtonCore, + stateAttrMap: PlayButtonDataAttrs, + selector: selectPlayback, + action: (core, state) => core.toggle(state), }); export namespace PlayButton { diff --git a/packages/react/src/ui/playback-rate-button/playback-rate-button.tsx b/packages/react/src/ui/playback-rate-button/playback-rate-button.tsx index af82b596..e0d14346 100644 --- a/packages/react/src/ui/playback-rate-button/playback-rate-button.tsx +++ b/packages/react/src/ui/playback-rate-button/playback-rate-button.tsx @@ -1,14 +1,10 @@ 'use client'; import { PlaybackRateButtonCore, PlaybackRateButtonDataAttrs } from '@videojs/core'; -import { logMissingFeature, selectPlaybackRate } from '@videojs/core/dom'; -import type { ForwardedRef } from 'react'; -import { forwardRef, useState } from 'react'; +import { selectPlaybackRate } from '@videojs/core/dom'; -import { usePlayer } from '../../player/context'; import type { UIComponentProps } from '../../utils/types'; -import { renderElement } from '../../utils/use-render'; -import { useButton } from '../hooks/use-button'; +import { createMediaButton } from '../create-media-button'; export interface PlaybackRateButtonProps extends UIComponentProps<'button', PlaybackRateButtonCore.State>, @@ -30,40 +26,12 @@ export interface PlaybackRateButtonProps * /> * ``` */ -export const PlaybackRateButton = forwardRef(function PlaybackRateButton( - componentProps: PlaybackRateButtonProps, - forwardedRef: ForwardedRef -) { - const { render, className, style, label, disabled, ...elementProps } = componentProps; - - const playbackRate = usePlayer(selectPlaybackRate); - - const [core] = useState(() => new PlaybackRateButtonCore()); - core.setProps({ label, disabled }); - - const { getButtonProps, buttonRef } = useButton({ - displayName: 'PlaybackRateButton', - onActivate: () => core.cycle(playbackRate!), - isDisabled: () => disabled || !playbackRate, - }); - - if (!playbackRate) { - if (__DEV__) logMissingFeature('PlaybackRateButton', 'playbackRate'); - return null; - } - - const state = core.getState(playbackRate); - - return renderElement( - 'button', - { render, className, style }, - { - state, - stateAttrMap: PlaybackRateButtonDataAttrs, - ref: [forwardedRef, buttonRef], - props: [core.getAttrs(state), elementProps, getButtonProps()], - } - ); +export const PlaybackRateButton = createMediaButton({ + displayName: 'PlaybackRateButton', + core: PlaybackRateButtonCore, + stateAttrMap: PlaybackRateButtonDataAttrs, + selector: selectPlaybackRate, + action: (core, state) => core.cycle(state), }); export namespace PlaybackRateButton { diff --git a/packages/react/src/ui/popover/popover-context.tsx b/packages/react/src/ui/popover/context.tsx similarity index 74% rename from packages/react/src/ui/popover/popover-context.tsx rename to packages/react/src/ui/popover/context.tsx index cd3dd18c..a69c8ab3 100644 --- a/packages/react/src/ui/popover/popover-context.tsx +++ b/packages/react/src/ui/popover/context.tsx @@ -1,13 +1,14 @@ 'use client'; -import type { PopoverCore } from '@videojs/core'; -import type { PopoverHandle } from '@videojs/core/dom'; +import type { PopoverCore, StateAttrMap } from '@videojs/core'; +import type { PopoverApi } from '@videojs/core/dom'; import { createContext, useContext } from 'react'; export interface PopoverContextValue { core: PopoverCore; - popover: PopoverHandle; + popover: PopoverApi; state: PopoverCore.State; + stateAttrMap: StateAttrMap; anchorName: string; popupId: string; } diff --git a/packages/react/src/ui/popover/index.ts b/packages/react/src/ui/popover/index.ts index f4f698df..9282d7dc 100644 --- a/packages/react/src/ui/popover/index.ts +++ b/packages/react/src/ui/popover/index.ts @@ -1,2 +1,2 @@ +export { type PopoverContextValue, usePopoverContext } from './context'; export * as Popover from './index.parts'; -export { type PopoverContextValue, usePopoverContext } from './popover-context'; diff --git a/packages/react/src/ui/popover/popover-arrow.tsx b/packages/react/src/ui/popover/popover-arrow.tsx index efb145e6..02bf40e4 100644 --- a/packages/react/src/ui/popover/popover-arrow.tsx +++ b/packages/react/src/ui/popover/popover-arrow.tsx @@ -1,36 +1,19 @@ 'use client'; -import { PopoverDataAttrs, type PopoverState } from '@videojs/core'; -import { forwardRef } from 'react'; +import type { PopoverState } from '@videojs/core'; import type { UIComponentProps } from '../../utils/types'; -import { renderElement } from '../../utils/use-render'; -import { usePopoverContext } from './popover-context'; +import { createContextPart } from '../create-context-part'; +import { usePopoverContext } from './context'; export interface PopoverArrowProps extends UIComponentProps<'div', PopoverState> {} /** Decorative arrow pointing from the popup toward the trigger. Hidden from assistive technology. */ -export const PopoverArrow = forwardRef(function PopoverArrow( - { render, className, style, ...elementProps }, - forwardedRef -) { - const { state } = usePopoverContext(); - - return renderElement( - 'div', - { render, className, style }, - { - state, - stateAttrMap: PopoverDataAttrs, - ref: forwardedRef, - props: [ - { - 'aria-hidden': 'true' as const, - }, - elementProps, - ], - } - ); +export const PopoverArrow = createContextPart({ + displayName: 'PopoverArrow', + tag: 'div', + useContext: usePopoverContext, + staticProps: { 'aria-hidden': 'true' as const }, }); export namespace PopoverArrow { diff --git a/packages/react/src/ui/popover/popover-popup.tsx b/packages/react/src/ui/popover/popover-popup.tsx index dd2b85bf..5b826ef3 100644 --- a/packages/react/src/ui/popover/popover-popup.tsx +++ b/packages/react/src/ui/popover/popover-popup.tsx @@ -1,6 +1,6 @@ 'use client'; -import { PopoverDataAttrs, type PopoverState } from '@videojs/core'; +import type { PopoverState } from '@videojs/core'; import { getAnchorPositionStyle, resolveOffsets } from '@videojs/core/dom'; import { supportsAnchorPositioning } from '@videojs/utils/dom'; import type { CSSProperties } from 'react'; @@ -9,7 +9,7 @@ import { forwardRef, useCallback, useLayoutEffect, useMemo, useRef, useState } f import type { UIComponentProps } from '../../utils/types'; import { useComposedRefs } from '../../utils/use-composed-refs'; import { renderElement } from '../../utils/use-render'; -import { usePopoverContext } from './popover-context'; +import { usePopoverContext } from './context'; export interface PopoverPopupProps extends UIComponentProps<'div', PopoverState> {} @@ -20,7 +20,7 @@ export const PopoverPopup = forwardRef(functi { render, className, style, ...elementProps }, forwardedRef ) { - const { core, popover, state, anchorName, popupId } = usePopoverContext(); + const { core, popover, state, stateAttrMap, anchorName, popupId } = usePopoverContext(); const internalRef = useRef(null); const popupRef = useCallback( @@ -106,7 +106,7 @@ export const PopoverPopup = forwardRef(functi { render, className, style }, { state, - stateAttrMap: PopoverDataAttrs, + stateAttrMap, ref: composedRef, props: [ { diff --git a/packages/react/src/ui/popover/popover-root.tsx b/packages/react/src/ui/popover/popover-root.tsx index 10a4ac33..4063eda7 100644 --- a/packages/react/src/ui/popover/popover-root.tsx +++ b/packages/react/src/ui/popover/popover-root.tsx @@ -1,14 +1,14 @@ 'use client'; -import { type PopoverProps as CorePopoverProps, PopoverCore } from '@videojs/core'; -import { createPopover, createTransitionHandler, type PopoverChangeDetails } from '@videojs/core/dom'; +import { type PopoverProps as CorePopoverProps, PopoverCore, PopoverDataAttrs } from '@videojs/core'; +import { createPopover, createTransition, type PopoverChangeDetails } from '@videojs/core/dom'; import { useSnapshot } from '@videojs/store/react'; import type { ReactNode } from 'react'; import { useEffect, useState } from 'react'; import { useLatestRef } from '../../utils/use-latest-ref'; import { useSafeId } from '../../utils/use-safe-id'; -import { PopoverContextProvider } from './popover-context'; +import { PopoverContextProvider } from './context'; export interface PopoverRootProps extends CorePopoverProps { /** Called when the popover open state changes (fires immediately, before animations). */ @@ -46,7 +46,7 @@ export function PopoverRoot({ const [popover] = useState(() => { const instance = createPopover({ - transition: createTransitionHandler(), + transition: createTransition(), onOpenChange: (nextOpen: boolean, details: PopoverChangeDetails) => { onOpenChangeRef.current?.(nextOpen, details); }, @@ -71,12 +71,12 @@ export function PopoverRoot({ const anchorName = useSafeId(); const popupId = useSafeId('popup-'); - // Sync controlled open prop -> internal interaction state. + // Sync controlled open prop -> internal input state. useEffect(() => { if (controlledOpen === undefined) return; - const { active: interactionOpen } = popover.interaction.current; - if (controlledOpen === interactionOpen) return; + const { active: inputOpen } = popover.input.current; + if (controlledOpen === inputOpen) return; if (controlledOpen) { popover.open('click'); @@ -88,11 +88,14 @@ export function PopoverRoot({ // Cleanup on unmount useEffect(() => () => popover.destroy(), [popover]); - const interaction = useSnapshot(popover.interaction); - const state = core.getState(interaction); + const input = useSnapshot(popover.input); + core.setInput(input); + const state = core.getState(); return ( - {children} + + {children} + ); } diff --git a/packages/react/src/ui/popover/popover-trigger.tsx b/packages/react/src/ui/popover/popover-trigger.tsx index 315070cb..95a4d39d 100644 --- a/packages/react/src/ui/popover/popover-trigger.tsx +++ b/packages/react/src/ui/popover/popover-trigger.tsx @@ -1,12 +1,12 @@ 'use client'; -import { PopoverDataAttrs, type PopoverState } from '@videojs/core'; +import type { PopoverState } from '@videojs/core'; import { getAnchorNameStyle } from '@videojs/core/dom'; import { forwardRef, useCallback, useMemo } from 'react'; import type { UIComponentProps } from '../../utils/types'; import { renderElement } from '../../utils/use-render'; -import { usePopoverContext } from './popover-context'; +import { usePopoverContext } from './context'; export interface PopoverTriggerProps extends UIComponentProps<'button', PopoverState> {} @@ -15,7 +15,7 @@ export const PopoverTrigger = forwardRef { render, className, style, ...elementProps }, forwardedRef ) { - const { core, popover, state, anchorName, popupId } = usePopoverContext(); + const { core, popover, state, stateAttrMap, anchorName, popupId } = usePopoverContext(); const triggerRef = useCallback( (el: HTMLButtonElement | null) => { @@ -36,7 +36,7 @@ export const PopoverTrigger = forwardRef { render, className, style }, { state, - stateAttrMap: PopoverDataAttrs, + stateAttrMap, ref: [forwardedRef, triggerRef], props: [ { diff --git a/packages/react/src/ui/poster/poster.tsx b/packages/react/src/ui/poster/poster.tsx index 259441b5..831f2c40 100644 --- a/packages/react/src/ui/poster/poster.tsx +++ b/packages/react/src/ui/poster/poster.tsx @@ -40,11 +40,13 @@ export const Poster = forwardRef(function Poster( return null; } + core.setMedia(playback); + return renderElement( 'img', { render, className, style }, { - state: core.getState(playback), + state: core.getState(), stateAttrMap: PosterDataAttrs, ref: [forwardedRef], props: [elementProps], diff --git a/packages/react/src/ui/seek-button/seek-button.tsx b/packages/react/src/ui/seek-button/seek-button.tsx index a675d461..62d4dc4b 100644 --- a/packages/react/src/ui/seek-button/seek-button.tsx +++ b/packages/react/src/ui/seek-button/seek-button.tsx @@ -1,14 +1,10 @@ '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 { selectTime } from '@videojs/core/dom'; -import { usePlayer } from '../../player/context'; import type { UIComponentProps } from '../../utils/types'; -import { renderElement } from '../../utils/use-render'; -import { useButton } from '../hooks/use-button'; +import { createMediaButton } from '../create-media-button'; export interface SeekButtonProps extends UIComponentProps<'button', SeekButtonCore.State>, SeekButtonCore.Props {} @@ -29,40 +25,12 @@ export interface SeekButtonProps extends UIComponentProps<'button', SeekButtonCo * /> * ``` */ -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 const SeekButton = createMediaButton({ + displayName: 'SeekButton', + core: SeekButtonCore, + stateAttrMap: SeekButtonDataAttrs, + selector: selectTime, + action: (core, state) => core.seek(state), }); export namespace SeekButton { diff --git a/packages/react/src/ui/slider/slider-context.tsx b/packages/react/src/ui/slider/context.tsx similarity index 75% rename from packages/react/src/ui/slider/slider-context.tsx rename to packages/react/src/ui/slider/context.tsx index 5fb3bdea..5b8b69e3 100644 --- a/packages/react/src/ui/slider/slider-context.tsx +++ b/packages/react/src/ui/slider/context.tsx @@ -16,12 +16,14 @@ export interface SliderContextValue { formatValue?: ((value: number, type: 'current' | 'pointer') => string) | undefined; } -const SliderContext = createContext(undefined); +const SliderContext = createContext(null); export function SliderProvider({ value, children }: { value: SliderContextValue; children: React.ReactNode }) { return {children}; } -export function useSliderContext(): SliderContextValue | undefined { - return useContext(SliderContext); +export function useSliderContext(): SliderContextValue { + const ctx = useContext(SliderContext); + if (!ctx) throw new Error('Slider compound components must be used within a Slider.Root'); + return ctx; } diff --git a/packages/react/src/ui/slider/slider-buffer.tsx b/packages/react/src/ui/slider/slider-buffer.tsx index 4d4b125c..4c7e06ff 100644 --- a/packages/react/src/ui/slider/slider-buffer.tsx +++ b/packages/react/src/ui/slider/slider-buffer.tsx @@ -1,35 +1,18 @@ 'use client'; import type { SliderState } from '@videojs/core'; -import type { ForwardedRef } from 'react'; -import { forwardRef } from 'react'; import type { UIComponentProps } from '../../utils/types'; -import { renderElement } from '../../utils/use-render'; -import { useSliderContext } from './slider-context'; +import { createContextPart } from '../create-context-part'; +import { useSliderContext } from './context'; export interface SliderBufferProps extends UIComponentProps<'div', SliderState> {} /** Displays the buffered range on the slider track. */ -export const SliderBuffer = forwardRef(function SliderBuffer( - componentProps: SliderBufferProps, - forwardedRef: ForwardedRef -) { - const { render, className, style, ...elementProps } = componentProps; - - const context = useSliderContext(); - if (!context) return null; - - return renderElement( - 'div', - { render, className, style }, - { - state: context.state, - stateAttrMap: context.stateAttrMap, - ref: forwardedRef, - props: [elementProps], - } - ); +export const SliderBuffer = createContextPart({ + displayName: 'SliderBuffer', + tag: 'div', + useContext: useSliderContext, }); export namespace SliderBuffer { diff --git a/packages/react/src/ui/slider/slider-fill.tsx b/packages/react/src/ui/slider/slider-fill.tsx index 573fedfe..58c9f572 100644 --- a/packages/react/src/ui/slider/slider-fill.tsx +++ b/packages/react/src/ui/slider/slider-fill.tsx @@ -1,35 +1,18 @@ 'use client'; import type { SliderState } from '@videojs/core'; -import type { ForwardedRef } from 'react'; -import { forwardRef } from 'react'; import type { UIComponentProps } from '../../utils/types'; -import { renderElement } from '../../utils/use-render'; -import { useSliderContext } from './slider-context'; +import { createContextPart } from '../create-context-part'; +import { useSliderContext } from './context'; export interface SliderFillProps extends UIComponentProps<'div', SliderState> {} /** Displays the filled portion from start to the current value. */ -export const SliderFill = forwardRef(function SliderFill( - componentProps: SliderFillProps, - forwardedRef: ForwardedRef -) { - const { render, className, style, ...elementProps } = componentProps; - - const context = useSliderContext(); - if (!context) return null; - - return renderElement( - 'div', - { render, className, style }, - { - state: context.state, - stateAttrMap: context.stateAttrMap, - ref: forwardedRef, - props: [elementProps], - } - ); +export const SliderFill = createContextPart({ + displayName: 'SliderFill', + tag: 'div', + useContext: useSliderContext, }); export namespace SliderFill { diff --git a/packages/react/src/ui/slider/slider-root.tsx b/packages/react/src/ui/slider/slider-root.tsx index 7c224136..821b9636 100644 --- a/packages/react/src/ui/slider/slider-root.tsx +++ b/packages/react/src/ui/slider/slider-root.tsx @@ -8,7 +8,7 @@ import { forwardRef, useState } from 'react'; import type { UIComponentProps } from '../../utils/types'; import { renderElement } from '../../utils/use-render'; import { useSlider } from '../hooks/use-slider'; -import { SliderProvider } from './slider-context'; +import { SliderProvider } from './context'; export interface SliderRootProps extends UIComponentProps<'div', SliderCore.State>, SliderCore.Props { onValueChange?: ((value: number) => void) | undefined; @@ -44,9 +44,6 @@ export const SliderRoot = forwardRef(function SliderRoot( const [core] = useState(() => new SliderCore()); core.setProps({ label, min, max, step, largeStep, orientation, disabled, thumbAlignment }); - const { min: resolvedMin, max: resolvedMax, step: resolvedStep, largeStep: resolvedLargeStep } = core.props; - const range = resolvedMax - resolvedMin || 1; - const { state, cssVars, @@ -55,10 +52,13 @@ export const SliderRoot = forwardRef(function SliderRoot( rootProps, thumbProps, } = useSlider({ - computeState: (interaction) => core.getState(interaction, value), + computeState: (input) => { + core.setInput(input); + return core.getSliderState(value); + }, getPercent: () => core.percentFromValue(value), - getStepPercent: () => (resolvedStep / range) * 100, - getLargeStepPercent: () => (resolvedLargeStep / range) * 100, + getStepPercent: () => core.getStepPercent(), + getLargeStepPercent: () => core.getLargeStepPercent(), orientation, disabled, adjustPercent: (rawPercent, thumbSize, trackSize) => diff --git a/packages/react/src/ui/slider/slider-thumb.tsx b/packages/react/src/ui/slider/slider-thumb.tsx index 7aab831b..118b700b 100644 --- a/packages/react/src/ui/slider/slider-thumb.tsx +++ b/packages/react/src/ui/slider/slider-thumb.tsx @@ -6,7 +6,7 @@ import { forwardRef } from 'react'; import type { UIComponentProps } from '../../utils/types'; import { renderElement } from '../../utils/use-render'; -import { useSliderContext } from './slider-context'; +import { useSliderContext } from './context'; export interface SliderThumbProps extends UIComponentProps<'div', SliderState> {} @@ -18,8 +18,6 @@ export const SliderThumb = forwardRef(function SliderThumb( const { render, className, style, ...elementProps } = componentProps; const context = useSliderContext(); - if (!context) return null; - const { state, thumbRef, thumbProps, getAttrs } = context; const attrs = getAttrs(state); diff --git a/packages/react/src/ui/slider/slider-track.tsx b/packages/react/src/ui/slider/slider-track.tsx index c3b256d9..8990a40e 100644 --- a/packages/react/src/ui/slider/slider-track.tsx +++ b/packages/react/src/ui/slider/slider-track.tsx @@ -1,35 +1,18 @@ 'use client'; import type { SliderState } from '@videojs/core'; -import type { ForwardedRef } from 'react'; -import { forwardRef } from 'react'; import type { UIComponentProps } from '../../utils/types'; -import { renderElement } from '../../utils/use-render'; -import { useSliderContext } from './slider-context'; +import { createContextPart } from '../create-context-part'; +import { useSliderContext } from './context'; export interface SliderTrackProps extends UIComponentProps<'div', SliderState> {} /** Contains the slider's visual track and interactive hit zone. */ -export const SliderTrack = forwardRef(function SliderTrack( - componentProps: SliderTrackProps, - forwardedRef: ForwardedRef -) { - const { render, className, style, ...elementProps } = componentProps; - - const context = useSliderContext(); - if (!context) return null; - - return renderElement( - 'div', - { render, className, style }, - { - state: context.state, - stateAttrMap: context.stateAttrMap, - ref: forwardedRef, - props: [elementProps], - } - ); +export const SliderTrack = createContextPart({ + displayName: 'SliderTrack', + tag: 'div', + useContext: useSliderContext, }); export namespace SliderTrack { diff --git a/packages/react/src/ui/slider/slider-value.tsx b/packages/react/src/ui/slider/slider-value.tsx index 6e09a3e0..7857fc2e 100644 --- a/packages/react/src/ui/slider/slider-value.tsx +++ b/packages/react/src/ui/slider/slider-value.tsx @@ -6,7 +6,7 @@ import { forwardRef } from 'react'; import type { UIComponentProps } from '../../utils/types'; import { renderElement } from '../../utils/use-render'; -import { useSliderContext } from './slider-context'; +import { useSliderContext } from './context'; export interface SliderValueProps extends UIComponentProps<'output', SliderState> { /** Which slider value to display: the current position or the pointer position. */ @@ -23,8 +23,6 @@ export const SliderValue = forwardRef(function SliderValue( const { render, className, style, type = 'current', format, ...elementProps } = componentProps; const context = useSliderContext(); - if (!context) return null; - const { state, pointerValue, formatValue } = context; const rawValue = type === 'pointer' ? pointerValue : state.value; diff --git a/packages/react/src/ui/slider/tests/slider.test.tsx b/packages/react/src/ui/slider/tests/slider.test.tsx index 8ee56dea..f3c990cb 100644 --- a/packages/react/src/ui/slider/tests/slider.test.tsx +++ b/packages/react/src/ui/slider/tests/slider.test.tsx @@ -9,9 +9,9 @@ import { SliderThumb } from '../slider-thumb'; import { SliderTrack } from '../slider-track'; import { SliderValue } from '../slider-value'; -const { mockSliderHandle } = vi.hoisted(() => ({ - mockSliderHandle: () => ({ - interaction: { +const { mockSliderApi } = vi.hoisted(() => ({ + mockSliderApi: () => ({ + input: { current: { pointerPercent: 0, dragPercent: 0, @@ -37,7 +37,7 @@ const { mockSliderHandle } = vi.hoisted(() => ({ vi.mock('@videojs/core/dom', async (importOriginal) => { const orig: Record = await importOriginal(); - return { ...orig, createSlider: vi.fn(mockSliderHandle) }; + return { ...orig, createSlider: vi.fn(mockSliderApi) }; }); vi.mock('@videojs/store/react', () => ({ @@ -97,10 +97,8 @@ describe('SliderTrack', () => { expect(container.querySelector('[data-testid="track"]')).toBeTruthy(); }); - it('returns null outside of SliderRoot', () => { - const { container } = render(); - - expect(container.firstElementChild).toBeNull(); + it('throws outside of SliderRoot', () => { + expect(() => render()).toThrow('Slider compound components must be used within a Slider.Root'); }); it('forwards ref', () => { @@ -126,10 +124,8 @@ describe('SliderFill', () => { expect(container.querySelector('[data-testid="fill"]')).toBeTruthy(); }); - it('returns null outside of SliderRoot', () => { - const { container } = render(); - - expect(container.firstElementChild).toBeNull(); + it('throws outside of SliderRoot', () => { + expect(() => render()).toThrow('Slider compound components must be used within a Slider.Root'); }); }); @@ -144,10 +140,8 @@ describe('SliderBuffer', () => { expect(container.querySelector('[data-testid="buffer"]')).toBeTruthy(); }); - it('returns null outside of SliderRoot', () => { - const { container } = render(); - - expect(container.firstElementChild).toBeNull(); + it('throws outside of SliderRoot', () => { + expect(() => render()).toThrow('Slider compound components must be used within a Slider.Root'); }); }); @@ -162,10 +156,8 @@ describe('SliderThumb', () => { expect(container.querySelector('[data-testid="thumb"]')).toBeTruthy(); }); - it('returns null outside of SliderRoot', () => { - const { container } = render(); - - expect(container.firstElementChild).toBeNull(); + it('throws outside of SliderRoot', () => { + expect(() => render()).toThrow('Slider compound components must be used within a Slider.Root'); }); it('forwards ref', () => { @@ -203,10 +195,8 @@ describe('SliderValue', () => { expect(el?.tagName).toBe('OUTPUT'); }); - it('returns null outside of SliderRoot', () => { - const { container } = render(); - - expect(container.firstElementChild).toBeNull(); + it('throws outside of SliderRoot', () => { + expect(() => render()).toThrow('Slider compound components must be used within a Slider.Root'); }); it('displays rounded value by default', () => { diff --git a/packages/react/src/ui/time-slider/tests/time-slider.test.tsx b/packages/react/src/ui/time-slider/tests/time-slider.test.tsx index 0c5f1292..ff2a1e31 100644 --- a/packages/react/src/ui/time-slider/tests/time-slider.test.tsx +++ b/packages/react/src/ui/time-slider/tests/time-slider.test.tsx @@ -12,9 +12,9 @@ import { TimeSliderRoot } from '../time-slider-root'; // --- Hoisted mock data (available inside vi.mock factories) --- -const { mockSliderHandle, mockTimeState, mockBufferState } = vi.hoisted(() => ({ - mockSliderHandle: () => ({ - interaction: { +const { mockSliderApi, mockTimeState, mockBufferState } = vi.hoisted(() => ({ + mockSliderApi: () => ({ + input: { current: { pointerPercent: 0, dragPercent: 0, @@ -52,7 +52,7 @@ const { mockSliderHandle, mockTimeState, mockBufferState } = vi.hoisted(() => ({ vi.mock('@videojs/core/dom', async (importOriginal) => { const orig: Record = await importOriginal(); - return { ...orig, createSlider: vi.fn(mockSliderHandle) }; + return { ...orig, createSlider: vi.fn(mockSliderApi) }; }); vi.mock('@videojs/store/react', () => ({ diff --git a/packages/react/src/ui/time-slider/time-slider-root.tsx b/packages/react/src/ui/time-slider/time-slider-root.tsx index 24f72c54..b5c17636 100644 --- a/packages/react/src/ui/time-slider/time-slider-root.tsx +++ b/packages/react/src/ui/time-slider/time-slider-root.tsx @@ -3,14 +3,14 @@ import { TimeSliderCore, TimeSliderDataAttrs } from '@videojs/core'; import { getTimeSliderCSSVars, logMissingFeature, selectBuffer, selectTime } from '@videojs/core/dom'; import { formatTime } from '@videojs/utils/time'; -import { forwardRef, useRef, useState } from 'react'; +import { forwardRef, useState } from 'react'; import { usePlayer } from '../../player/context'; import type { UIComponentProps } from '../../utils/types'; import { useLatestRef } from '../../utils/use-latest-ref'; import { renderElement } from '../../utils/use-render'; import { useSlider } from '../hooks/use-slider'; -import { SliderProvider } from '../slider/slider-context'; +import { SliderProvider } from '../slider/context'; const noopSeek = (): Promise => Promise.resolve(0); @@ -46,65 +46,38 @@ export const TimeSliderRoot = forwardRef( // Keep a ref to the latest media state for callbacks that fire outside the render cycle. const mediaRef = useLatestRef(time && buffer ? { ...time, ...buffer } : null); - // Holds the target time (seconds) between commit and seek completion, preventing the slider - // from snapping back to the stale `currentTime` while the async seek settles. - const pendingSeekRef = useRef(null); - const duration = time?.duration ?? 0; - const range = duration || 1; const { state, cssVars, rootRef, thumbRef, rootProps, thumbProps } = useSlider({ - computeState: (interaction) => { + computeState: (input) => { + core.setInput(input); if (!time || !buffer) { - return core.getTimeState( - { currentTime: 0, duration: 0, seeking: false, seek: noopSeek, buffered: [], seekable: [] }, - interaction - ); + core.setMedia({ + currentTime: 0, + duration: 0, + seeking: false, + seek: noopSeek, + buffered: [], + seekable: [], + }); + } else { + core.setMedia({ ...time, ...buffer }); } - const baseState = core.getTimeState({ ...time, ...buffer }, interaction); - - // After drag release, `dragging` resets before the async seek completes. Hold the - // slider at the committed position until `currentTime` catches up. - const pending = pendingSeekRef.current; - if (!interaction.dragging && pending !== null) { - if (Math.abs(time.currentTime - pending) < 0.5) { - // Seek landed — clear pending. - pendingSeekRef.current = null; - } else { - const dur = time.duration || 1; - const fillPercent = (pending / dur) * 100; - return { ...baseState, value: pending, fillPercent }; - } - } - - return baseState; + return core.getState(); }, getPercent: () => (duration > 0 ? ((time?.currentTime ?? 0) / duration) * 100 : 0), - getStepPercent: () => (step / range) * 100, - getLargeStepPercent: () => (largeStep / range) * 100, + getStepPercent: () => core.getStepPercent(), + getLargeStepPercent: () => core.getLargeStepPercent(), orientation, disabled, commitThrottle, adjustPercent: (rawPercent, thumbSize, trackSize) => core.adjustPercentForAlignment(rawPercent, thumbSize, trackSize), getCSSVars: getTimeSliderCSSVars, - onValueChange: (percent) => { - // Track the target position for visual hold during pointer interaction. - const media = mediaRef.current; - if (media) { - pendingSeekRef.current = (percent / 100) * (media.duration || 0); - } - }, onValueCommit: (percent) => { const media = mediaRef.current; - if (!media) return; - const seconds = (percent / 100) * (media.duration || 0); - pendingSeekRef.current = seconds; - // seek() is async — catch rejection if the media target isn't attached yet. - media.seek(seconds).catch(() => { - pendingSeekRef.current = null; - }); + if (media) media.seek(core.valueFromPercent(percent)); }, onDragStart, onDragEnd, diff --git a/packages/react/src/ui/time/time-value.tsx b/packages/react/src/ui/time/time-value.tsx index 36798027..718a9ca9 100644 --- a/packages/react/src/ui/time/time-value.tsx +++ b/packages/react/src/ui/time/time-value.tsx @@ -37,7 +37,8 @@ export const Value = forwardRef(function Value( return null; } - const state = core.getState(time); + core.setMedia(time); + const state = core.getState(); const content = state.negative ? ( <> diff --git a/packages/react/src/ui/volume-slider/tests/volume-slider.test.tsx b/packages/react/src/ui/volume-slider/tests/volume-slider.test.tsx index c9087344..3d43ee7c 100644 --- a/packages/react/src/ui/volume-slider/tests/volume-slider.test.tsx +++ b/packages/react/src/ui/volume-slider/tests/volume-slider.test.tsx @@ -11,9 +11,9 @@ import { VolumeSliderRoot } from '../volume-slider-root'; // --- Hoisted mock data (available inside vi.mock factories) --- -const { mockSliderHandle, mockVolumeState } = vi.hoisted(() => ({ - mockSliderHandle: () => ({ - interaction: { +const { mockSliderApi, mockVolumeState } = vi.hoisted(() => ({ + mockSliderApi: () => ({ + input: { current: { pointerPercent: 0, dragPercent: 0, @@ -48,7 +48,7 @@ const { mockSliderHandle, mockVolumeState } = vi.hoisted(() => ({ vi.mock('@videojs/core/dom', async (importOriginal) => { const orig: Record = await importOriginal(); - return { ...orig, createSlider: vi.fn(mockSliderHandle) }; + return { ...orig, createSlider: vi.fn(mockSliderApi) }; }); vi.mock('@videojs/store/react', () => ({ diff --git a/packages/react/src/ui/volume-slider/volume-slider-root.tsx b/packages/react/src/ui/volume-slider/volume-slider-root.tsx index a0454889..2622ea13 100644 --- a/packages/react/src/ui/volume-slider/volume-slider-root.tsx +++ b/packages/react/src/ui/volume-slider/volume-slider-root.tsx @@ -9,7 +9,7 @@ import type { UIComponentProps } from '../../utils/types'; import { useLatestRef } from '../../utils/use-latest-ref'; import { renderElement } from '../../utils/use-render'; import { useSlider } from '../hooks/use-slider'; -import { SliderProvider } from '../slider/slider-context'; +import { SliderProvider } from '../slider/context'; const noopVolume = { volume: 0, @@ -50,15 +50,14 @@ export const VolumeSliderRoot = forwardRef({ - computeState: (interaction) => { - if (!volume) { - return core.getVolumeState(noopVolume, interaction); - } - return core.getVolumeState(volume, interaction); + computeState: (input) => { + core.setInput(input); + core.setMedia(volume ?? noopVolume); + return core.getState(); }, getPercent: () => (volume ? volume.volume * 100 : 0), - getStepPercent: () => step, - getLargeStepPercent: () => largeStep, + getStepPercent: () => core.getStepPercent(), + getLargeStepPercent: () => core.getLargeStepPercent(), orientation, disabled, adjustPercent: (rawPercent, thumbSize, trackSize) => diff --git a/packages/store/src/core/selector.ts b/packages/store/src/core/selector.ts index 87d156f2..3a9937fb 100644 --- a/packages/store/src/core/selector.ts +++ b/packages/store/src/core/selector.ts @@ -1,6 +1,7 @@ import { pick } from '@videojs/utils/object'; import { AbortControllerRegistry } from './abort-controller-registry'; import { throwNoTargetError } from './errors'; +import type { Selector } from './shallow-equal'; import type { AnySlice, InferSliceState, StateContext } from './slice'; const stateContext: StateContext = { @@ -18,20 +19,27 @@ const stateContext: StateContext = { * ```ts * const selectPlayback = createSelector(playbackSlice); * selectPlayback(store.state); // { paused, play, pause, ... } | undefined + * selectPlayback.displayName; // 'playback' (from slice name) * ``` * - * @param slice - The feature slice to create a selector for. + * @param slice - The slice to create a selector for. */ -export function createSelector(slice: S): (state: object) => InferSliceState | undefined { +export function createSelector(slice: S): Selector | undefined> { const initialState = slice.state(stateContext); const keys = Object.keys(initialState as object); const firstKey = keys[0]; - if (!firstKey) return () => undefined; - return (state) => { - // WARN: Could be the source of a bug if two slices have overlapping state keys - if (!(firstKey in state)) return undefined; - return pick(state as Record, keys) as InferSliceState; - }; + if (!firstKey) { + return Object.assign(() => undefined, { displayName: slice.name }); + } + + return Object.assign( + (state: object) => { + // WARN: Could be the source of a bug if two slices have overlapping state keys + if (!(firstKey in state)) return undefined; + return pick(state as Record, keys) as InferSliceState; + }, + { displayName: slice.name } + ); } diff --git a/packages/store/src/core/shallow-equal.ts b/packages/store/src/core/shallow-equal.ts index c4a2b626..b22ee0c1 100644 --- a/packages/store/src/core/shallow-equal.ts +++ b/packages/store/src/core/shallow-equal.ts @@ -1,4 +1,7 @@ -export type Selector = (state: State) => Result; +export interface Selector { + (state: State): Result; + displayName?: string | undefined; +} export type Comparator = (a: T, b: T) => boolean; diff --git a/packages/store/src/core/slice.ts b/packages/store/src/core/slice.ts index 4815c8d3..851c1e13 100644 --- a/packages/store/src/core/slice.ts +++ b/packages/store/src/core/slice.ts @@ -47,6 +47,8 @@ export interface StateContext { // ---------------------------------------- export interface SliceConfig { + /** Debug label. Used as `displayName` on selectors created from this slice. */ + name?: string; state: (ctx: StateContext) => State; attach?: (ctx: AttachContext) => void; } diff --git a/packages/store/src/core/tests/selector.test.ts b/packages/store/src/core/tests/selector.test.ts index 0c1c08d0..2da7859c 100644 --- a/packages/store/src/core/tests/selector.test.ts +++ b/packages/store/src/core/tests/selector.test.ts @@ -8,6 +8,7 @@ interface MockMedia { describe('createSelector', () => { const volumeSlice = defineSlice()({ + name: 'volume', state: ({ target }) => ({ volume: 1, muted: false, @@ -19,6 +20,7 @@ describe('createSelector', () => { }); const playbackSlice = defineSlice()({ + name: 'playback', state: () => ({ paused: true, ended: false, @@ -86,4 +88,30 @@ describe('createSelector', () => { // But structurally equal (for shallowEqual comparison) expect(selected1).toEqual(selected2); }); + + it('exposes displayName from slice name', () => { + const selectVolume = createSelector(volumeSlice); + + expect(selectVolume.displayName).toBe('volume'); + }); + + it('omits displayName when slice has no name', () => { + const unnamedSlice = defineSlice()({ + state: () => ({ paused: true }), + }); + const selector = createSelector(unnamedSlice); + + expect(selector.displayName).toBeUndefined(); + }); + + it('returns undefined for empty-state slice', () => { + const emptySlice = defineSlice()({ + name: 'empty', + state: () => ({}), + }); + const selector = createSelector(emptySlice); + + expect(selector({})).toBeUndefined(); + expect(selector.displayName).toBe('empty'); + }); });