feat(core): add toggleControls to controls feature (#1280)

This commit is contained in:
rahim
2026-04-08 00:10:10 -07:00
committed by GitHub
parent 53835d7355
commit 11f305713b
7 changed files with 93 additions and 1 deletions
+2
View File
@@ -168,6 +168,8 @@ export interface MediaControlsState {
userActive: boolean;
/** Whether controls should be visible (userActive || paused). */
controlsVisible: boolean;
/** Toggle controls visibility. Returns the new `controlsVisible` value. */
toggleControls(): boolean;
}
export interface MediaPlaybackRateState {
@@ -73,6 +73,7 @@ function createControlsState(overrides: Partial<MediaControlsState> = {}): Media
return {
userActive: true,
controlsVisible: true,
toggleControls: () => true,
...overrides,
};
}
@@ -9,9 +9,15 @@ const TAP_THRESHOLD = 250;
export const controlsFeature = definePlayerFeature({
name: 'controls',
state: (): MediaControlsState => ({
state: ({ get, set }): MediaControlsState => ({
userActive: true,
controlsVisible: true,
toggleControls() {
// Fallback before attach — no idle timer, just flip state.
const next = !get().userActive;
set({ userActive: next, controlsVisible: next });
return next as boolean;
},
}),
attach({ target, signal, get, set }) {
@@ -53,6 +59,18 @@ export const controlsFeature = definePlayerFeature({
set({ userActive: false, controlsVisible: computeVisible(false) });
}
// Expose toggleControls with access to idle timer.
set({
toggleControls() {
if (get().controlsVisible) {
setInactive();
} else {
setActive();
}
return get().controlsVisible;
},
});
// Touch tap-to-toggle
let pointerDownTime = 0;
@@ -287,6 +287,73 @@ describe('controlsFeature', () => {
});
});
describe('toggleControls', () => {
it('hides controls when visible and playing', () => {
const video = createMockVideo({ paused: false });
const { store } = createPlayerStore(video);
const result = store.state.toggleControls();
flush();
expect(store.state.userActive).toBe(false);
expect(store.state.controlsVisible).toBe(false);
expect(result).toBe(false);
});
it('shows controls when hidden', () => {
const video = createMockVideo({ paused: false });
const { store } = createPlayerStore(video);
// First toggle to hide
store.state.toggleControls();
flush();
expect(store.state.controlsVisible).toBe(false);
// Second toggle to show
const result = store.state.toggleControls();
flush();
expect(store.state.userActive).toBe(true);
expect(store.state.controlsVisible).toBe(true);
expect(result).toBe(true);
});
it('reschedules idle timer when showing controls', () => {
const video = createMockVideo({ paused: false });
const { store } = createPlayerStore(video);
// Hide controls
store.state.toggleControls();
flush();
// Show controls
store.state.toggleControls();
flush();
expect(store.state.controlsVisible).toBe(true);
// Should hide again after idle delay
vi.advanceTimersByTime(IDLE_DELAY);
flush();
expect(store.state.userActive).toBe(false);
expect(store.state.controlsVisible).toBe(false);
});
it('keeps controlsVisible true when toggling off while paused', () => {
const video = createMockVideo({ paused: true });
const { store } = createPlayerStore(video);
const result = store.state.toggleControls();
flush();
expect(store.state.userActive).toBe(false);
expect(store.state.controlsVisible).toBe(true);
expect(result).toBe(true);
});
});
describe('null container', () => {
it('does not track activity without container', () => {
const video = createMockVideo({ paused: false });
+1
View File
@@ -7,6 +7,7 @@ import type { AnySlice, InferSliceState, StateContext } from './slice';
const stateContext: StateContext<unknown> = {
target: throwNoTargetError,
signals: new AbortControllerRegistry(),
get: throwNoTargetError,
set: throwNoTargetError,
};
+2
View File
@@ -40,6 +40,8 @@ export interface StateContext<Target> {
* (e.g., loading a new source cancels pending seeks).
*/
signals: AbortControllerRegistry;
/** Read current slice state. Safe to use inside action closures (not during `state()` init). */
get: () => Readonly<Record<string, unknown>>;
/** Patch the slice state. Safe to use inside action closures (not during `state()` init). */
set: (partial: Record<string, unknown>) => void;
}
+1
View File
@@ -38,6 +38,7 @@ export function createStore<Target = unknown>(): <State>(
return target!;
},
signals,
get: () => state.current as Readonly<Record<string, unknown>>,
set: (partial) => state.patch(partial as Partial<State>),
} satisfies StateContext<Target>);