From 11f305713bba718d54944a57684e8c135fe4e0e1 Mon Sep 17 00:00:00 2001 From: rahim Date: Wed, 8 Apr 2026 00:10:10 -0700 Subject: [PATCH] feat(core): add toggleControls to controls feature (#1280) --- packages/core/src/core/media/state.ts | 2 + .../ui/controls/tests/controls-core.test.ts | 1 + .../core/src/dom/store/features/controls.ts | 20 +++++- .../dom/store/features/tests/controls.test.ts | 67 +++++++++++++++++++ packages/store/src/core/selector.ts | 1 + packages/store/src/core/slice.ts | 2 + packages/store/src/core/store.ts | 1 + 7 files changed, 93 insertions(+), 1 deletion(-) diff --git a/packages/core/src/core/media/state.ts b/packages/core/src/core/media/state.ts index 2f5bfbbe..2cc2181b 100644 --- a/packages/core/src/core/media/state.ts +++ b/packages/core/src/core/media/state.ts @@ -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 { 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 238b74ad..7d162723 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 @@ -73,6 +73,7 @@ function createControlsState(overrides: Partial = {}): Media return { userActive: true, controlsVisible: true, + toggleControls: () => true, ...overrides, }; } diff --git a/packages/core/src/dom/store/features/controls.ts b/packages/core/src/dom/store/features/controls.ts index 477e4d7a..05740bb1 100644 --- a/packages/core/src/dom/store/features/controls.ts +++ b/packages/core/src/dom/store/features/controls.ts @@ -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; diff --git a/packages/core/src/dom/store/features/tests/controls.test.ts b/packages/core/src/dom/store/features/tests/controls.test.ts index 8a0de63c..568c0f19 100644 --- a/packages/core/src/dom/store/features/tests/controls.test.ts +++ b/packages/core/src/dom/store/features/tests/controls.test.ts @@ -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 }); diff --git a/packages/store/src/core/selector.ts b/packages/store/src/core/selector.ts index 99ed4e39..302d29df 100644 --- a/packages/store/src/core/selector.ts +++ b/packages/store/src/core/selector.ts @@ -7,6 +7,7 @@ import type { AnySlice, InferSliceState, StateContext } from './slice'; const stateContext: StateContext = { target: throwNoTargetError, signals: new AbortControllerRegistry(), + get: throwNoTargetError, set: throwNoTargetError, }; diff --git a/packages/store/src/core/slice.ts b/packages/store/src/core/slice.ts index fe9d8831..609dfabb 100644 --- a/packages/store/src/core/slice.ts +++ b/packages/store/src/core/slice.ts @@ -40,6 +40,8 @@ export interface StateContext { * (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>; /** Patch the slice state. Safe to use inside action closures (not during `state()` init). */ set: (partial: Record) => void; } diff --git a/packages/store/src/core/store.ts b/packages/store/src/core/store.ts index 819da733..fe809c9b 100644 --- a/packages/store/src/core/store.ts +++ b/packages/store/src/core/store.ts @@ -38,6 +38,7 @@ export function createStore(): ( return target!; }, signals, + get: () => state.current as Readonly>, set: (partial) => state.patch(partial as Partial), } satisfies StateContext);