refactor(store): v2 (#362)

This commit is contained in:
rahim
2026-01-31 23:34:11 +11:00
committed by GitHub
parent 9c725803a6
commit fe892cb911
64 changed files with 1883 additions and 2959 deletions
+5 -14
View File
@@ -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<HTMLMediaElement>()({
initialState: {
export const bufferFeature = defineFeature<HTMLMediaElement>()({
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<HTMLMediaElement>()({
listen(target, 'progress', update, { signal });
listen(target, 'emptied', update, { signal });
},
request: {},
});
export type BufferState = InferFeatureState<typeof bufferFeature>;
export type BufferRequests = InferFeatureRequests<typeof bufferFeature>;
@@ -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<HTMLMediaElement>()({
initialState: {
export const playbackFeature = defineFeature<HTMLMediaElement>()({
state: ({ task }) => ({
/** Whether playback is paused. */
paused: true,
/** Whether playback has reached the end. */
@@ -18,7 +13,28 @@ export const playbackFeature = createFeature<HTMLMediaElement>()({
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<HTMLMediaElement>()({
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<typeof playbackFeature>;
export type PlaybackRequests = InferFeatureRequests<typeof playbackFeature>;
+17 -21
View File
@@ -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<HTMLMediaElement>()({
initialState: {
export const sourceFeature = defineFeature<HTMLMediaElement>()({
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<HTMLMediaElement>()({
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<typeof sourceFeature>;
export type SourceRequests = InferFeatureRequests<typeof sourceFeature>;
@@ -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();
@@ -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();
});
});
});
@@ -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();
@@ -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();
});
});
});
});
@@ -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);
+17 -21
View File
@@ -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<HTMLMediaElement>()({
initialState: {
export const timeFeature = defineFeature<HTMLMediaElement>()({
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<HTMLMediaElement>()({
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<typeof timeFeature>;
export type TimeRequests = InferFeatureRequests<typeof timeFeature>;
+27 -26
View File
@@ -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<HTMLMediaElement>()({
initialState: {
export const volumeFeature = defineFeature<HTMLMediaElement>()({
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<HTMLMediaElement>()({
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<typeof volumeFeature>;
export type VolumeRequests = InferFeatureRequests<typeof volumeFeature>;