mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
refactor(store): v2 (#362)
This commit is contained in:
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user