diff --git a/.claude/skills/component/references/lit.md b/.claude/skills/component/references/lit.md index 227addef..a5093156 100644 --- a/.claude/skills/component/references/lit.md +++ b/.claude/skills/component/references/lit.md @@ -48,8 +48,7 @@ All store-related controllers live in `@videojs/store/lit`. See that package for ```ts class MyElement extends ReactiveElement { - #state = new SnapshotController(this, store.state); - #play = new RequestController(this, context, 'play'); + #store = new StoreController(this, context); } ``` @@ -71,10 +70,10 @@ Internal utility that resolves a store from either a direct instance or context. ```ts // Direct store — value available immediately -const state = new SnapshotController(this, store.state); +const store = new StoreController(this, store); -// Context — value available after context resolves via StateController from createStore -const state = new StateController(this); +// Context — value available after context resolves +const store = new StoreController(this, context); ``` Controllers handle both cases transparently. The `StoreAccessor`: @@ -90,7 +89,7 @@ Controllers handle both cases transparently. The `StoreAccessor`: Always export an explicit host type for controllers and mixins: ```ts -export type SnapshotControllerHost = ReactiveControllerHost & HTMLElement; +export type StoreControllerHost = ReactiveControllerHost & HTMLElement; export type ProviderMixinHost = ReactiveElement & EventTarget; ``` @@ -104,16 +103,29 @@ export type ProviderMixinHost = ReactiveElement & EventTarget; Mixins are for **store provision only**, not behavior. Behavior goes in controllers. -### createStoreProviderMixin +### Store Mixins -Creates a mixin that provides a store via context: +`createStore()` returns mixins for different use cases: ```ts -const { StoreProviderMixin } = createStore({ features: [playbackFeature] }); +const { StoreMixin, ProviderMixin, ContainerMixin } = createStore({ + features: [playbackFeature], +}); -class MyPlayer extends StoreProviderMixin(ReactiveElement) { +// StoreMixin: provides store AND auto-attaches slotted media +class MyPlayer extends StoreMixin(ReactiveElement) { + // Store provided to all descendants, media auto-attached +} + +// ProviderMixin: provides store only (no auto-attach) +class MyProvider extends ProviderMixin(ReactiveElement) { // Store provided to all descendants } + +// ContainerMixin: consumes store from context, auto-attaches media +class MyControls extends ContainerMixin(ReactiveElement) { + // Inherits store from parent provider +} ``` - Creates store on first access (lazy) diff --git a/.claude/skills/docs/templates/readme.md b/.claude/skills/docs/templates/readme.md index 4732c54f..fa45ac35 100644 --- a/.claude/skills/docs/templates/readme.md +++ b/.claude/skills/docs/templates/readme.md @@ -152,21 +152,20 @@ Explanation. Core concepts: - Target -- Features +- Features (defineFeature, state factory) - Feature Type Inference -- Requests -- Request Metadata -- Store -- Request Configuration (Keys, Cancels, Guards) +- Actions (task helper, sync mutations) +- Action Metadata (store.meta()) +- Store (flat access, pending tasks) +- Task Configuration (Keys, Modes, Cancels) - Error Handling -- Queue ### @videojs/store/lit Core concepts: -- Controllers (SnapshotController, RequestController, TasksController) -- Mixins (StoreMixin, StoreProviderMixin, StoreAttachMixin) +- Controllers (StoreController, SubscriptionController) +- Mixins (StoreMixin, ProviderMixin, ContainerMixin) - createStore factory pattern - Context API @@ -217,14 +216,14 @@ Always include at top: ```markdown npm install @videojs/store -import { createFeature, createStore } from '@videojs/store'; +import { createStore } from '@videojs/store'; const store = createStore({ features: [playbackFeature, volumeFeature], }); store.attach(videoElement); -await store.request.play(); +await store.play(); ``` ### "How It's Different" Table @@ -243,10 +242,10 @@ Use when comparing to alternatives: Always show type inference for TypeScript users: ```ts -import type { InferFeatureRequests, InferFeatureState } from '@videojs/store'; +import type { InferFeatureState } from '@videojs/store'; type VolumeState = InferFeatureState; -type VolumeRequests = InferFeatureRequests; +// { volume: number; muted: boolean; changeVolume: ...; toggleMute: ... } ``` ### Links Footer diff --git a/packages/core/src/dom/store/features/buffer.ts b/packages/core/src/dom/store/features/buffer.ts index a50922aa..bed47e7f 100644 --- a/packages/core/src/dom/store/features/buffer.ts +++ b/packages/core/src/dom/store/features/buffer.ts @@ -1,20 +1,15 @@ -import type { InferFeatureRequests, InferFeatureState } from '@videojs/store'; +import type { InferFeatureState } from '@videojs/store'; -import { createFeature } from '@videojs/store'; +import { defineFeature } from '@videojs/store'; import { listen, serializeTimeRanges } from '@videojs/utils/dom'; -/** - * Buffer feature for HTMLMediaElement. - * - * Tracks buffered and seekable time ranges. Read-only (no requests). - */ -export const bufferFeature = createFeature()({ - initialState: { +export const bufferFeature = defineFeature()({ + state: () => ({ /** Buffered time ranges as [start, end] tuples. */ buffered: [] as [number, number][], /** Seekable time ranges as [start, end] tuples. */ seekable: [] as [number, number][], - }, + }), getSnapshot: ({ target }) => ({ buffered: serializeTimeRanges(target.buffered), @@ -25,10 +20,6 @@ export const bufferFeature = createFeature()({ listen(target, 'progress', update, { signal }); listen(target, 'emptied', update, { signal }); }, - - request: {}, }); export type BufferState = InferFeatureState; - -export type BufferRequests = InferFeatureRequests; diff --git a/packages/core/src/dom/store/features/playback.ts b/packages/core/src/dom/store/features/playback.ts index d3619d3c..48b9124e 100644 --- a/packages/core/src/dom/store/features/playback.ts +++ b/packages/core/src/dom/store/features/playback.ts @@ -1,15 +1,10 @@ -import type { InferFeatureRequests, InferFeatureState } from '@videojs/store'; +import type { InferFeatureState } from '@videojs/store'; -import { createFeature } from '@videojs/store'; +import { defineFeature } from '@videojs/store'; import { listen } from '@videojs/utils/dom'; -/** - * Playback feature for HTMLMediaElement. - * - * Tracks core playback state and provides play/pause control. - */ -export const playbackFeature = createFeature()({ - initialState: { +export const playbackFeature = defineFeature()({ + state: ({ task }) => ({ /** Whether playback is paused. */ paused: true, /** Whether playback has reached the end. */ @@ -18,7 +13,28 @@ export const playbackFeature = createFeature()({ started: false, /** Whether playback is stalled waiting for data. */ waiting: false, - }, + + /** Start playback. Returns when playback begins. */ + play() { + return task({ + key: 'playback', + mode: 'shared', + async handler({ target }) { + await target.play(); + }, + }); + }, + + /** Pause playback immediately. */ + pause() { + return task({ + key: 'playback', + handler({ target }) { + target.pause(); + }, + }); + }, + }), getSnapshot: ({ target }) => ({ paused: target.paused, @@ -34,20 +50,6 @@ export const playbackFeature = createFeature()({ listen(target, 'playing', update, { signal }); listen(target, 'waiting', update, { signal }); }, - - request: { - /** Start playback. Returns when playback begins. */ - play: async (_, { target }) => { - await target.play(); - }, - - /** Pause playback immediately. */ - pause: (_, { target }) => { - target.pause(); - }, - }, }); export type PlaybackState = InferFeatureState; - -export type PlaybackRequests = InferFeatureRequests; diff --git a/packages/core/src/dom/store/features/source.ts b/packages/core/src/dom/store/features/source.ts index a1f9fa03..faaeba1b 100644 --- a/packages/core/src/dom/store/features/source.ts +++ b/packages/core/src/dom/store/features/source.ts @@ -1,20 +1,27 @@ -import type { InferFeatureRequests, InferFeatureState } from '@videojs/store'; +import type { InferFeatureState } from '@videojs/store'; -import { createFeature } from '@videojs/store'; +import { CANCEL_ALL, defineFeature } from '@videojs/store'; import { listen } from '@videojs/utils/dom'; -/** - * Source feature for HTMLMediaElement. - * - * Tracks current source and loading state, provides source change control. - */ -export const sourceFeature = createFeature()({ - initialState: { +export const sourceFeature = defineFeature()({ + state: ({ task }) => ({ /** Current media source URL (null if none). */ source: null as string | null, /** Whether enough data is loaded to begin playback. */ canPlay: false, - }, + /** Load a new media source. Cancels all pending operations. Returns the new source URL. */ + loadSource(src: string) { + return task({ + key: 'source', + cancels: [CANCEL_ALL], + handler({ target }) { + target.src = src; + target.load(); + return src; + }, + }); + }, + }), getSnapshot: ({ target }) => ({ source: target.currentSrc || target.src || null, @@ -27,17 +34,6 @@ export const sourceFeature = createFeature()({ listen(target, 'loadstart', update, { signal }); listen(target, 'emptied', update, { signal }); }, - - request: { - /** Change media source and begin loading. Returns the new source URL. */ - changeSource: (src: string, { target }) => { - target.src = src; - target.load(); - return src; - }, - }, }); export type SourceState = InferFeatureState; - -export type SourceRequests = InferFeatureRequests; diff --git a/packages/core/src/dom/store/features/tests/buffer.test.ts b/packages/core/src/dom/store/features/tests/buffer.test.ts index 5fad047a..4f87821a 100644 --- a/packages/core/src/dom/store/features/tests/buffer.test.ts +++ b/packages/core/src/dom/store/features/tests/buffer.test.ts @@ -3,23 +3,6 @@ import { describe, expect, it, vi } from 'vitest'; import { bufferFeature } from '../buffer'; describe('bufferFeature', () => { - describe('feature structure', () => { - it('has unique id symbol', () => { - expect(bufferFeature.id).toBeTypeOf('symbol'); - }); - - it('has correct initial state', () => { - expect(bufferFeature.initialState).toEqual({ - buffered: [], - seekable: [], - }); - }); - - it('has no request handlers', () => { - expect(Object.keys(bufferFeature.request)).toHaveLength(0); - }); - }); - describe('getSnapshot', () => { it('captures buffered and seekable ranges from video element', () => { const video = createMockVideo({ @@ -29,7 +12,8 @@ describe('bufferFeature', () => { const snapshot = bufferFeature.getSnapshot({ target: video, - initialState: bufferFeature.initialState, + get: () => ({ buffered: [], seekable: [] }), + initialState: { buffered: [], seekable: [] }, }); expect(snapshot).toEqual({ @@ -49,7 +33,8 @@ describe('bufferFeature', () => { const snapshot = bufferFeature.getSnapshot({ target: video, - initialState: bufferFeature.initialState, + get: () => ({ buffered: [], seekable: [] }), + initialState: { buffered: [], seekable: [] }, }); expect(snapshot.buffered).toEqual([ @@ -68,7 +53,12 @@ describe('bufferFeature', () => { const update = vi.fn(); const controller = new AbortController(); - bufferFeature.subscribe({ target: video, update, signal: controller.signal }); + bufferFeature.subscribe({ + target: video, + update, + signal: controller.signal, + get: () => ({ buffered: [], seekable: [] }), + }); video.dispatchEvent(new Event('progress')); expect(update).toHaveBeenCalled(); @@ -82,7 +72,12 @@ describe('bufferFeature', () => { const update = vi.fn(); const controller = new AbortController(); - bufferFeature.subscribe({ target: video, update, signal: controller.signal }); + bufferFeature.subscribe({ + target: video, + update, + signal: controller.signal, + get: () => ({ buffered: [], seekable: [] }), + }); video.dispatchEvent(new Event('emptied')); expect(update).toHaveBeenCalled(); diff --git a/packages/core/src/dom/store/features/tests/playback.test.ts b/packages/core/src/dom/store/features/tests/playback.test.ts index 998175bf..d8c1350f 100644 --- a/packages/core/src/dom/store/features/tests/playback.test.ts +++ b/packages/core/src/dom/store/features/tests/playback.test.ts @@ -1,42 +1,21 @@ +import { createStore } from '@videojs/store'; +import { noop } from '@videojs/utils/function'; import { describe, expect, it, vi } from 'vitest'; +import type { PlaybackState } from '../playback'; import { playbackFeature } from '../playback'; +const mockState = () => + ({ + paused: true, + ended: false, + started: false, + waiting: false, + play: noop, + pause: noop, + }) as unknown as PlaybackState; + describe('playbackFeature', () => { - describe('feature structure', () => { - it('has unique id symbol', () => { - expect(playbackFeature.id).toBeTypeOf('symbol'); - }); - - it('has correct initial state', () => { - expect(playbackFeature.initialState).toEqual({ - paused: true, - ended: false, - started: false, - waiting: false, - }); - }); - - it('has all request handlers', () => { - expect(playbackFeature.request.play).toBeDefined(); - expect(playbackFeature.request.pause).toBeDefined(); - }); - - it('request handlers have correct structure', () => { - expect(playbackFeature.request.play).toMatchObject({ - key: 'play', - guard: [], - handler: expect.any(Function), - }); - - expect(playbackFeature.request.pause).toMatchObject({ - key: 'pause', - guard: [], - handler: expect.any(Function), - }); - }); - }); - describe('getSnapshot', () => { it('captures current playback state from video element', () => { const video = createMockVideo({ @@ -48,7 +27,8 @@ describe('playbackFeature', () => { const snapshot = playbackFeature.getSnapshot({ target: video, - initialState: playbackFeature.initialState, + get: mockState, + initialState: mockState(), }); expect(snapshot).toEqual({ @@ -67,7 +47,8 @@ describe('playbackFeature', () => { const snapshot = playbackFeature.getSnapshot({ target: video, - initialState: playbackFeature.initialState, + get: mockState, + initialState: mockState(), }); expect(snapshot.waiting).toBe(true); @@ -81,7 +62,8 @@ describe('playbackFeature', () => { const snapshot = playbackFeature.getSnapshot({ target: video, - initialState: playbackFeature.initialState, + get: mockState, + initialState: mockState(), }); expect(snapshot.started).toBe(true); @@ -95,7 +77,8 @@ describe('playbackFeature', () => { const snapshot = playbackFeature.getSnapshot({ target: video, - initialState: playbackFeature.initialState, + get: mockState, + initialState: mockState(), }); expect(snapshot.started).toBe(true); @@ -108,7 +91,12 @@ describe('playbackFeature', () => { const update = vi.fn(); const controller = new AbortController(); - playbackFeature.subscribe({ target: video, update, signal: controller.signal }); + playbackFeature.subscribe({ + target: video, + update, + signal: controller.signal, + get: mockState, + }); video.dispatchEvent(new Event('play')); expect(update).toHaveBeenCalled(); @@ -119,7 +107,12 @@ describe('playbackFeature', () => { const update = vi.fn(); const controller = new AbortController(); - playbackFeature.subscribe({ target: video, update, signal: controller.signal }); + playbackFeature.subscribe({ + target: video, + update, + signal: controller.signal, + get: mockState, + }); video.dispatchEvent(new Event('pause')); expect(update).toHaveBeenCalled(); @@ -130,7 +123,12 @@ describe('playbackFeature', () => { const update = vi.fn(); const controller = new AbortController(); - playbackFeature.subscribe({ target: video, update, signal: controller.signal }); + playbackFeature.subscribe({ + target: video, + update, + signal: controller.signal, + get: mockState, + }); video.dispatchEvent(new Event('ended')); expect(update).toHaveBeenCalled(); @@ -141,7 +139,12 @@ describe('playbackFeature', () => { const update = vi.fn(); const controller = new AbortController(); - playbackFeature.subscribe({ target: video, update, signal: controller.signal }); + playbackFeature.subscribe({ + target: video, + update, + signal: controller.signal, + get: mockState, + }); controller.abort(); video.dispatchEvent(new Event('play')); @@ -149,35 +152,29 @@ describe('playbackFeature', () => { }); }); - describe('request handlers', () => { - describe('play', () => { - it('calls play on target', async () => { - const video = createMockVideo({}); - video.play = vi.fn().mockResolvedValue(undefined); + describe('actions', () => { + it('play() calls play on target', async () => { + const video = createMockVideo({}); + video.play = vi.fn().mockResolvedValue(undefined); - await playbackFeature.request.play.handler(undefined, { - target: video, - signal: new AbortController().signal, - meta: null, - }); + const store = createStore({ features: [playbackFeature] }); + store.attach(video); - expect(video.play).toHaveBeenCalled(); - }); + await store.play(); + + expect(video.play).toHaveBeenCalled(); }); - describe('pause', () => { - it('calls pause on target', () => { - const video = createMockVideo({}); - video.pause = vi.fn(); + it('pause() calls pause on target', () => { + const video = createMockVideo({}); + video.pause = vi.fn(); - playbackFeature.request.pause.handler(undefined, { - target: video, - signal: new AbortController().signal, - meta: null, - }); + const store = createStore({ features: [playbackFeature] }); + store.attach(video); - expect(video.pause).toHaveBeenCalled(); - }); + store.pause(); + + expect(video.pause).toHaveBeenCalled(); }); }); }); diff --git a/packages/core/src/dom/store/features/tests/source.test.ts b/packages/core/src/dom/store/features/tests/source.test.ts index d3ec7b7e..60c2f2e1 100644 --- a/packages/core/src/dom/store/features/tests/source.test.ts +++ b/packages/core/src/dom/store/features/tests/source.test.ts @@ -1,30 +1,18 @@ +import { createStore } from '@videojs/store'; +import { noop } from '@videojs/utils/function'; import { describe, expect, it, vi } from 'vitest'; +import type { SourceState } from '../source'; import { sourceFeature } from '../source'; +const mockState = () => + ({ + source: null, + canPlay: false, + loadSource: noop, + }) as unknown as SourceState; + describe('sourceFeature', () => { - describe('feature structure', () => { - it('has unique id symbol', () => { - expect(sourceFeature.id).toBeTypeOf('symbol'); - }); - - it('has correct initial state', () => { - expect(sourceFeature.initialState).toEqual({ - source: null, - canPlay: false, - }); - }); - - it('has changeSource request handler', () => { - expect(sourceFeature.request.changeSource).toBeDefined(); - expect(sourceFeature.request.changeSource).toMatchObject({ - key: 'changeSource', - guard: [], - handler: expect.any(Function), - }); - }); - }); - describe('getSnapshot', () => { it('captures source state from video element', () => { const video = createMockVideo({ @@ -35,7 +23,8 @@ describe('sourceFeature', () => { const snapshot = sourceFeature.getSnapshot({ target: video, - initialState: sourceFeature.initialState, + get: mockState, + initialState: mockState(), }); expect(snapshot).toEqual({ @@ -52,7 +41,8 @@ describe('sourceFeature', () => { const snapshot = sourceFeature.getSnapshot({ target: video, - initialState: sourceFeature.initialState, + get: mockState, + initialState: mockState(), }); expect(snapshot.source).toBe(null); @@ -66,7 +56,12 @@ describe('sourceFeature', () => { const update = vi.fn(); const controller = new AbortController(); - sourceFeature.subscribe({ target: video, update, signal: controller.signal }); + sourceFeature.subscribe({ + target: video, + update, + signal: controller.signal, + get: mockState, + }); video.dispatchEvent(new Event('canplay')); expect(update).toHaveBeenCalled(); @@ -80,7 +75,12 @@ describe('sourceFeature', () => { const update = vi.fn(); const controller = new AbortController(); - sourceFeature.subscribe({ target: video, update, signal: controller.signal }); + sourceFeature.subscribe({ + target: video, + update, + signal: controller.signal, + get: mockState, + }); video.dispatchEvent(new Event('loadstart')); expect(update).toHaveBeenCalled(); @@ -91,24 +91,28 @@ describe('sourceFeature', () => { const update = vi.fn(); const controller = new AbortController(); - sourceFeature.subscribe({ target: video, update, signal: controller.signal }); + sourceFeature.subscribe({ + target: video, + update, + signal: controller.signal, + get: mockState, + }); video.dispatchEvent(new Event('emptied')); expect(update).toHaveBeenCalled(); }); }); - describe('request handlers', () => { - describe('changeSource', () => { - it('sets src on target and calls load', () => { + describe('actions', () => { + describe('loadSource', () => { + it('sets src on target and calls load', async () => { const video = createMockVideo({}); video.load = vi.fn(); - const result = sourceFeature.request.changeSource.handler('https://example.com/new.mp4', { - target: video, - signal: new AbortController().signal, - meta: null, - }); + const store = createStore({ features: [sourceFeature] }); + store.attach(video); + + const result = await store.loadSource('https://example.com/new.mp4'); expect(video.src).toBe('https://example.com/new.mp4'); expect(video.load).toHaveBeenCalled(); diff --git a/packages/core/src/dom/store/features/tests/time.test.ts b/packages/core/src/dom/store/features/tests/time.test.ts index 95f28baa..2585eee8 100644 --- a/packages/core/src/dom/store/features/tests/time.test.ts +++ b/packages/core/src/dom/store/features/tests/time.test.ts @@ -1,30 +1,18 @@ +import { createStore } from '@videojs/store'; +import { noop } from '@videojs/utils/function'; import { describe, expect, it, vi } from 'vitest'; +import type { TimeState } from '../time'; import { timeFeature } from '../time'; +const mockState = () => + ({ + currentTime: 0, + duration: 0, + seek: noop, + }) as unknown as TimeState; + describe('timeFeature', () => { - describe('feature structure', () => { - it('has unique id symbol', () => { - expect(timeFeature.id).toBeTypeOf('symbol'); - }); - - it('has correct initial state', () => { - expect(timeFeature.initialState).toEqual({ - currentTime: 0, - duration: 0, - }); - }); - - it('has seek request handler', () => { - expect(timeFeature.request.seek).toBeDefined(); - expect(timeFeature.request.seek).toMatchObject({ - key: 'seek', - guard: [], - handler: expect.any(Function), - }); - }); - }); - describe('getSnapshot', () => { it('captures current time state from video element', () => { const video = createMockVideo({ @@ -34,7 +22,8 @@ describe('timeFeature', () => { const snapshot = timeFeature.getSnapshot({ target: video, - initialState: timeFeature.initialState, + get: mockState, + initialState: mockState(), }); expect(snapshot).toEqual({ @@ -51,7 +40,8 @@ describe('timeFeature', () => { const snapshot = timeFeature.getSnapshot({ target: video, - initialState: timeFeature.initialState, + get: mockState, + initialState: mockState(), }); expect(snapshot.duration).toBe(0); @@ -64,7 +54,12 @@ describe('timeFeature', () => { const update = vi.fn(); const controller = new AbortController(); - timeFeature.subscribe({ target: video, update, signal: controller.signal }); + timeFeature.subscribe({ + target: video, + update, + signal: controller.signal, + get: mockState, + }); video.dispatchEvent(new Event('timeupdate')); expect(update).toHaveBeenCalled(); @@ -75,7 +70,12 @@ describe('timeFeature', () => { const update = vi.fn(); const controller = new AbortController(); - timeFeature.subscribe({ target: video, update, signal: controller.signal }); + timeFeature.subscribe({ + target: video, + update, + signal: controller.signal, + get: mockState, + }); video.dispatchEvent(new Event('durationchange')); expect(update).toHaveBeenCalled(); @@ -86,7 +86,12 @@ describe('timeFeature', () => { const update = vi.fn(); const controller = new AbortController(); - timeFeature.subscribe({ target: video, update, signal: controller.signal }); + timeFeature.subscribe({ + target: video, + update, + signal: controller.signal, + get: mockState, + }); video.dispatchEvent(new Event('seeked')); expect(update).toHaveBeenCalled(); @@ -97,23 +102,26 @@ describe('timeFeature', () => { const update = vi.fn(); const controller = new AbortController(); - timeFeature.subscribe({ target: video, update, signal: controller.signal }); + timeFeature.subscribe({ + target: video, + update, + signal: controller.signal, + get: mockState, + }); video.dispatchEvent(new Event('emptied')); expect(update).toHaveBeenCalled(); }); }); - describe('request handlers', () => { + describe('actions', () => { describe('seek', () => { it('sets currentTime on target and waits for seeked event', async () => { const video = createMockVideo({}); + const store = createStore({ features: [timeFeature] }); + store.attach(video); - const resultPromise = timeFeature.request.seek.handler(45, { - target: video, - signal: new AbortController().signal, - meta: null, - }); + const resultPromise = store.seek(45); expect(video.currentTime).toBe(45); @@ -123,21 +131,6 @@ describe('timeFeature', () => { const result = await resultPromise; expect(result).toBe(45); }); - - it('rejects if signal is aborted before seeked', async () => { - const video = createMockVideo({}); - const controller = new AbortController(); - - const resultPromise = timeFeature.request.seek.handler(30, { - target: video, - signal: controller.signal, - meta: null, - }); - - controller.abort(); - - await expect(resultPromise).rejects.toThrow(); - }); }); }); }); diff --git a/packages/core/src/dom/store/features/tests/volume.test.ts b/packages/core/src/dom/store/features/tests/volume.test.ts index 79a1ebaf..512cdf9c 100644 --- a/packages/core/src/dom/store/features/tests/volume.test.ts +++ b/packages/core/src/dom/store/features/tests/volume.test.ts @@ -1,26 +1,19 @@ +import { createStore } from '@videojs/store'; +import { noop } from '@videojs/utils/function'; import { describe, expect, it, vi } from 'vitest'; +import type { VolumeState } from '../volume'; import { volumeFeature } from '../volume'; +const mockState = () => + ({ + volume: 1, + muted: false, + changeVolume: noop, + toggleMute: noop, + }) as unknown as VolumeState; + describe('volumeFeature', () => { - describe('feature structure', () => { - it('has unique id symbol', () => { - expect(volumeFeature.id).toBeTypeOf('symbol'); - }); - - it('has correct initial state', () => { - expect(volumeFeature.initialState).toEqual({ - volume: 1, - muted: false, - }); - }); - - it('has all request handlers', () => { - expect(volumeFeature.request.changeVolume).toBeDefined(); - expect(volumeFeature.request.toggleMute).toBeDefined(); - }); - }); - describe('getSnapshot', () => { it('captures volume state from video element', () => { const video = createMockVideo({ @@ -30,7 +23,8 @@ describe('volumeFeature', () => { const snapshot = volumeFeature.getSnapshot({ target: video, - initialState: volumeFeature.initialState, + get: mockState, + initialState: mockState(), }); expect(snapshot).toEqual({ @@ -46,75 +40,70 @@ describe('volumeFeature', () => { const update = vi.fn(); const controller = new AbortController(); - volumeFeature.subscribe({ target: video, update, signal: controller.signal }); + volumeFeature.subscribe({ + target: video, + update, + signal: controller.signal, + get: mockState, + }); video.dispatchEvent(new Event('volumechange')); expect(update).toHaveBeenCalled(); }); }); - describe('request handlers', () => { + describe('actions', () => { describe('changeVolume', () => { - it('sets volume on target', () => { + it('sets volume on target', async () => { const video = createMockVideo({}); + const store = createStore({ features: [volumeFeature] }); + store.attach(video); - const result = volumeFeature.request.changeVolume.handler(0.7, { - target: video, - signal: new AbortController().signal, - meta: null, - }); + const result = await store.changeVolume(0.7); expect(video.volume).toBe(0.7); expect(result).toBe(0.7); }); - it('clamps volume to min 0', () => { + it('clamps volume to min 0', async () => { const video = createMockVideo({}); + const store = createStore({ features: [volumeFeature] }); + store.attach(video); - volumeFeature.request.changeVolume.handler(-0.5, { - target: video, - signal: new AbortController().signal, - meta: null, - }); + await store.changeVolume(-0.5); expect(video.volume).toBe(0); }); - it('clamps volume to max 1', () => { + it('clamps volume to max 1', async () => { const video = createMockVideo({}); + const store = createStore({ features: [volumeFeature] }); + store.attach(video); - volumeFeature.request.changeVolume.handler(1.5, { - target: video, - signal: new AbortController().signal, - meta: null, - }); + await store.changeVolume(1.5); expect(video.volume).toBe(1); }); }); describe('toggleMute', () => { - it('toggles mute from false to true', () => { + it('toggles mute from false to true', async () => { const video = createMockVideo({ muted: false }); + const store = createStore({ features: [volumeFeature] }); + store.attach(video); - const result = volumeFeature.request.toggleMute.handler(undefined, { - target: video, - signal: new AbortController().signal, - meta: null, - }); + const result = await store.toggleMute(); expect(video.muted).toBe(true); expect(result).toBe(true); }); - it('toggles mute from true to false', () => { + it('toggles mute from true to false', async () => { const video = createMockVideo({ muted: true }); + const store = createStore({ features: [volumeFeature] }); + store.attach(video); - const result = volumeFeature.request.toggleMute.handler(undefined, { - target: video, - signal: new AbortController().signal, - meta: null, - }); + const result = await store.toggleMute(); expect(video.muted).toBe(false); expect(result).toBe(false); diff --git a/packages/core/src/dom/store/features/time.ts b/packages/core/src/dom/store/features/time.ts index 904c08c9..dfa449fa 100644 --- a/packages/core/src/dom/store/features/time.ts +++ b/packages/core/src/dom/store/features/time.ts @@ -1,20 +1,27 @@ -import type { InferFeatureRequests, InferFeatureState } from '@videojs/store'; +import type { InferFeatureState } from '@videojs/store'; -import { createFeature } from '@videojs/store'; +import { defineFeature } from '@videojs/store'; import { listen, onEvent } from '@videojs/utils/dom'; -/** - * Time feature for HTMLMediaElement. - * - * Tracks current time and duration, provides seek control. - */ -export const timeFeature = createFeature()({ - initialState: { +export const timeFeature = defineFeature()({ + state: ({ task }) => ({ /** Current playback position in seconds. */ currentTime: 0, /** Total duration in seconds (0 if unknown). */ duration: 0, - }, + + /** Seek to a time in seconds. Returns the requested time. */ + seek(time: number) { + return task({ + key: 'seek', + async handler({ target, signal }) { + target.currentTime = time; + await onEvent(target, 'seeked', { signal }); + return target.currentTime; // actual position after seek + }, + }); + }, + }), getSnapshot: ({ target }) => ({ currentTime: target.currentTime, @@ -28,17 +35,6 @@ export const timeFeature = createFeature()({ listen(target, 'loadedmetadata', update, { signal }); listen(target, 'emptied', update, { signal }); }, - - request: { - /** Seek to a time in seconds. Returns the requested time. */ - seek: async (time: number, { target, signal }) => { - target.currentTime = time; - await onEvent(target, 'seeked', { signal }); - return target.currentTime; // actual position after seek - }, - }, }); export type TimeState = InferFeatureState; - -export type TimeRequests = InferFeatureRequests; diff --git a/packages/core/src/dom/store/features/volume.ts b/packages/core/src/dom/store/features/volume.ts index 3b050d62..deda7bf7 100644 --- a/packages/core/src/dom/store/features/volume.ts +++ b/packages/core/src/dom/store/features/volume.ts @@ -1,20 +1,37 @@ -import type { InferFeatureRequests, InferFeatureState } from '@videojs/store'; +import type { InferFeatureState } from '@videojs/store'; -import { createFeature } from '@videojs/store'; +import { defineFeature } from '@videojs/store'; import { listen } from '@videojs/utils/dom'; -/** - * Volume feature for HTMLMediaElement. - * - * Tracks volume and mute state, provides volume control. - */ -export const volumeFeature = createFeature()({ - initialState: { +export const volumeFeature = defineFeature()({ + state: ({ task }) => ({ /** Volume level from 0 (silent) to 1 (max). */ volume: 1, /** Whether audio is muted. */ muted: false, - }, + + /** Set volume (clamped 0-1). Returns the clamped value. */ + changeVolume(volume: number) { + return task({ + key: 'volume', + handler({ target }) { + target.volume = Math.max(0, Math.min(1, volume)); + return target.volume; + }, + }); + }, + + /** Toggle mute state. Returns new muted value. */ + toggleMute() { + return task({ + key: 'mute', + handler({ target }) { + target.muted = !target.muted; + return target.muted; + }, + }); + }, + }), getSnapshot: ({ target }) => ({ volume: target.volume, @@ -24,22 +41,6 @@ export const volumeFeature = createFeature()({ subscribe: ({ target, update, signal }) => { listen(target, 'volumechange', update, { signal }); }, - - request: { - /** Set volume (clamped 0-1). Returns the clamped value. */ - changeVolume: (volume: number, { target }) => { - target.volume = Math.max(0, Math.min(1, volume)); - return target.volume; - }, - - /** Toggle mute state. Returns new muted value. */ - toggleMute: (_, { target }) => { - target.muted = !target.muted; - return target.muted; - }, - }, }); export type VolumeState = InferFeatureState; - -export type VolumeRequests = InferFeatureRequests; diff --git a/packages/react/src/media/tests/video.test.tsx b/packages/react/src/media/tests/video.test.tsx index 1244dc82..1285bbc7 100644 --- a/packages/react/src/media/tests/video.test.tsx +++ b/packages/react/src/media/tests/video.test.tsx @@ -1,6 +1,6 @@ import { render } from '@testing-library/react'; -import { createFeature } from '@videojs/store'; +import { defineFeature } from '@videojs/store'; import { createStore, useStoreContext } from '@videojs/store/react'; import { describe, expect, it, vi } from 'vitest'; @@ -12,14 +12,16 @@ describe('video', () => { muted = false; } - const mockFeature = createFeature()({ - initialState: { volume: 1, muted: false }, + const mockFeature = defineFeature()({ + state: () => ({ + volume: 1, + muted: false, + }), getSnapshot: ({ target }) => ({ volume: target.volume, muted: target.muted, }), subscribe: () => {}, - request: {}, }); function createTestStore() { diff --git a/packages/store/README.md b/packages/store/README.md index 4e442634..727aba8a 100644 --- a/packages/store/README.md +++ b/packages/store/README.md @@ -20,27 +20,27 @@ npm install @videojs/store - **Write Path**: Send requests, coordinate execution, handle failures ```ts -import { createFeature, createStore } from '@videojs/store'; +import { createStore } from '@videojs/store'; const store = createStore({ - features: [playbackFeature, audioFeature], + features: [playbackFeature, volumeFeature], }); -store.attach(videoElement); //