fix(skin): improve buffering, overlays, and input feedback (#1547)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Sam Potts
2026-07-01 09:49:12 +10:00
committed by GitHub
co-authored by Cursor
parent 7c3be22e8a
commit 0de3fef878
55 changed files with 864 additions and 544 deletions
@@ -0,0 +1,99 @@
import { renderHook, waitFor } from '@testing-library/react';
import type { IndicatorLifecycleState, TransitionState } from '@videojs/core';
import { createState } from '@videojs/store';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const transitionMock = vi.hoisted(() => ({
createTransition: vi.fn(),
}));
vi.mock('@videojs/core/dom', async (importOriginal) => ({
...(await importOriginal<typeof import('@videojs/core/dom')>()),
createTransition: transitionMock.createTransition,
}));
import { useRenderedIndicatorState } from '../use-rendered-indicator-state';
interface TestIndicatorState extends IndicatorLifecycleState {
value: string;
}
function makeState(generation: number): TestIndicatorState {
return {
open: true,
generation,
value: String(generation),
transitionStarting: false,
transitionEnding: false,
};
}
function mockTransition() {
const state = createState<TransitionState>({ active: false, status: 'idle' });
const open = vi.fn(() => {
state.patch({ active: true, status: 'starting' });
return Promise.resolve();
});
const close = vi.fn(() => Promise.resolve());
const cancel = vi.fn();
const destroy = vi.fn();
transitionMock.createTransition.mockReturnValue({ state, open, close, cancel, destroy });
return { state, open, close, cancel, destroy };
}
beforeEach(() => {
transitionMock.createTransition.mockReset();
});
describe('useRenderedIndicatorState', () => {
it('replays the open transition on updates by default', async () => {
const transition = mockTransition();
const { rerender } = renderHook(({ current }) => useRenderedIndicatorState(current), {
initialProps: { current: makeState(1) },
});
await waitFor(() => {
expect(transition.open).toHaveBeenCalledTimes(1);
});
rerender({ current: makeState(2) });
await waitFor(() => {
expect(transition.open).toHaveBeenCalledTimes(2);
});
});
it('skips replaying the open transition on updates when disabled', async () => {
const transition = mockTransition();
const { rerender } = renderHook(({ current }) => useRenderedIndicatorState(current, { replayOnUpdate: false }), {
initialProps: { current: makeState(1) },
});
await waitFor(() => {
expect(transition.open).toHaveBeenCalledTimes(1);
});
rerender({ current: makeState(2) });
expect(transition.open).toHaveBeenCalledTimes(1);
});
it('cancels an ending transition when replay is disabled and an update arrives', async () => {
const transition = mockTransition();
const { rerender } = renderHook(({ current }) => useRenderedIndicatorState(current, { replayOnUpdate: false }), {
initialProps: { current: makeState(1) },
});
await waitFor(() => {
expect(transition.open).toHaveBeenCalledTimes(1);
});
transition.state.patch({ active: true, status: 'ending' });
rerender({ current: makeState(2) });
expect(transition.open).toHaveBeenCalledTimes(1);
expect(transition.cancel).toHaveBeenCalledOnce();
});
});
@@ -7,7 +7,7 @@ import { useState, useSyncExternalStore } from 'react';
import { useDestroy } from '../../utils/use-destroy';
import { useIndicatorVisibility } from './use-indicator-visibility';
import { useInputActionSubscription } from './use-input-action-subscription';
import { useRenderedIndicatorState } from './use-rendered-indicator-state';
import { type RenderedIndicatorOptions, useRenderedIndicatorState } from './use-rendered-indicator-state';
interface InputIndicatorRootCore<IndicatorState extends IndicatorLifecycleState, Props> {
readonly state: StoreState<IndicatorState>;
@@ -19,7 +19,8 @@ interface InputIndicatorRootCore<IndicatorState extends IndicatorLifecycleState,
export function useInputIndicatorRoot<IndicatorState extends IndicatorLifecycleState, Props>(
createCore: () => InputIndicatorRootCore<IndicatorState, Props>,
props: Props
props: Props,
options?: RenderedIndicatorOptions
) {
const [core] = useState(createCore);
useDestroy(core);
@@ -36,5 +37,5 @@ export function useInputIndicatorRoot<IndicatorState extends IndicatorLifecycleS
() => core.state.current
);
return useRenderedIndicatorState(currentState);
return useRenderedIndicatorState(currentState, options);
}
@@ -2,11 +2,18 @@
import { getRenderedIndicatorState, type IndicatorLifecycleState, isIndicatorPresent } from '@videojs/core';
import { createTransition } from '@videojs/core/dom';
import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
import { useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react';
import { useDestroy } from '../../utils/use-destroy';
export function useRenderedIndicatorState<State extends IndicatorLifecycleState>(currentState: State) {
export interface RenderedIndicatorOptions {
replayOnUpdate?: boolean | undefined;
}
export function useRenderedIndicatorState<State extends IndicatorLifecycleState>(
currentState: State,
options: RenderedIndicatorOptions = {}
) {
const elementRef = useRef<HTMLElement>(null);
const currentStateRef = useRef(currentState);
const snapshotRef = useRef(currentState);
@@ -22,13 +29,18 @@ export function useRenderedIndicatorState<State extends IndicatorLifecycleState>
const { generation, open } = currentState;
useEffect(() => {
useLayoutEffect(() => {
if (open) {
const nextState = currentStateRef.current;
if (nextState.generation !== generation) return;
snapshotRef.current = nextState;
void transition.open();
const transitionState = transition.state.current;
if (!transitionState.active || options.replayOnUpdate !== false) {
void transition.open(elementRef.current);
} else if (transitionState.status === 'ending') {
transition.cancel();
}
return;
}
@@ -36,7 +48,7 @@ export function useRenderedIndicatorState<State extends IndicatorLifecycleState>
if (active && status !== 'ending') {
void transition.close(elementRef.current);
}
}, [generation, open, transition]);
}, [generation, open, options.replayOnUpdate, transition]);
return {
elementRef,
@@ -18,7 +18,11 @@ export const VolumeIndicatorRoot = forwardRef(function VolumeIndicatorRoot(
forwardedRef: ForwardedRef<HTMLDivElement>
) {
const { render, className, style, closeDelay, ...elementProps } = componentProps;
const { elementRef, present, state } = useInputIndicatorRoot(() => new VolumeIndicatorCore(), { closeDelay });
const { elementRef, present, state } = useInputIndicatorRoot(
() => new VolumeIndicatorCore(),
{ closeDelay },
{ replayOnUpdate: false }
);
if (!present) return null;