refactor(store): remove queue and task system (#382)

This commit is contained in:
rahim
2026-02-03 15:06:06 +11:00
committed by GitHub
parent 8ed455c20a
commit 9fa172e8a4
35 changed files with 419 additions and 1620 deletions
+3
View File
@@ -0,0 +1,3 @@
export function hasMetadata(media: HTMLMediaElement): boolean {
return media.readyState >= HTMLMediaElement.HAVE_METADATA;
}
@@ -4,7 +4,7 @@ import { listen } from '@videojs/utils/dom';
import { definePlayerFeature } from '../../feature';
export const playbackFeature = definePlayerFeature({
state: ({ task }) => ({
state: ({ target }) => ({
/** Whether playback is paused. */
paused: true,
/** Whether playback has reached the end. */
@@ -13,26 +13,13 @@ export const playbackFeature = definePlayerFeature({
started: false,
/** Whether playback is stalled waiting for data. */
waiting: false,
/** Start playback. Returns when playback begins. */
/** Start playback. */
play() {
return task({
key: 'playback',
mode: 'shared',
async handler({ target }) {
await target.media.play();
},
});
return target().media.play();
},
/** Pause playback immediately. */
pause() {
return task({
key: 'playback',
handler({ target }) {
target.media.pause();
},
});
target().media.pause();
},
}),
+9 -13
View File
@@ -1,27 +1,23 @@
import type { InferSliceState } from '@videojs/store';
import { CANCEL_ALL } from '@videojs/store';
import { listen } from '@videojs/utils/dom';
import { definePlayerFeature } from '../../feature';
export const sourceFeature = definePlayerFeature({
state: ({ task }) => ({
state: ({ target, abort }) => ({
/** 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. */
/** Load a new media source. Returns the new source URL. */
loadSource(src: string) {
return task({
key: 'source',
cancels: [CANCEL_ALL],
handler({ target }) {
target.media.src = src;
target.media.load();
return src;
},
});
abort(); // Cancel pending operations (e.g., seek)
const { media } = target();
media.src = src;
media.load();
return src;
},
}),
@@ -1,8 +1,9 @@
import { createStore } from '@videojs/store';
import { combine, createStore } from '@videojs/store';
import { describe, expect, it, vi } from 'vitest';
import type { PlayerTarget } from '../../../types';
import { sourceFeature } from '../source';
import { timeFeature } from '../time';
describe('sourceFeature', () => {
describe('attach', () => {
@@ -116,6 +117,28 @@ describe('sourceFeature', () => {
expect(video.load).toHaveBeenCalled();
expect(result).toBe('https://example.com/new.mp4');
});
it('aborts pending operations when loading new source', async () => {
const video = createMockVideo({
readyState: HTMLMediaElement.HAVE_METADATA,
});
video.load = vi.fn();
const store = createStore<PlayerTarget>()(combine(sourceFeature, timeFeature));
store.attach({ media: video, container: null });
// Start a seek that will wait for seeked event
const seekPromise = store.seek(30);
// Load new source before seek completes - should abort the seek
store.loadSource('https://example.com/new.mp4');
// Seek should resolve immediately (aborted)
const result = await seekPromise;
expect(result).toBe(30); // Returns current position
expect(video.load).toHaveBeenCalled();
});
});
});
});
@@ -110,6 +110,47 @@ describe('timeFeature', () => {
const result = await resultPromise;
expect(result).toBe(45);
});
it('aborts pending seek on detach', async () => {
const video = createMockVideo({});
const store = createStore<PlayerTarget>()(timeFeature);
const detach = store.attach({ media: video, container: null });
const resultPromise = store.seek(45);
expect(video.currentTime).toBe(45);
// Detach before seeked event fires
detach();
// Should resolve with current time (seek was aborted)
const result = await resultPromise;
expect(result).toBe(45);
});
it('supersedes previous seek when new seek starts', async () => {
const video = createMockVideo({});
const store = createStore<PlayerTarget>()(timeFeature);
store.attach({ media: video, container: null });
// Start first seek
const seek1Promise = store.seek(10);
// Start second seek before first completes (supersedes)
const seek2Promise = store.seek(20);
expect(video.currentTime).toBe(20);
// First seek should resolve immediately (aborted)
const result1 = await seek1Promise;
expect(result1).toBe(20); // Returns current position
// Fire seeked for second seek
video.dispatchEvent(new Event('seeked'));
const result2 = await seek2Promise;
expect(result2).toBe(20);
});
});
});
});
@@ -118,7 +159,8 @@ function createMockVideo(
overrides: Partial<{
currentTime: number;
duration: number;
}>
readyState: number;
}> = {}
): HTMLVideoElement {
const video = document.createElement('video');
@@ -128,6 +170,12 @@ function createMockVideo(
if (overrides.duration !== undefined) {
Object.defineProperty(video, 'duration', { value: overrides.duration, writable: false, configurable: true });
}
// Default to HAVE_METADATA so seek tests work without waiting for loadedmetadata
Object.defineProperty(video, 'readyState', {
value: overrides.readyState ?? HTMLMediaElement.HAVE_METADATA,
writable: false,
configurable: true,
});
return video;
}
+40 -19
View File
@@ -1,27 +1,46 @@
import type { InferSliceState } from '@videojs/store';
import { listen, onEvent } from '@videojs/utils/dom';
import { noop } from '@videojs/utils/function';
import { definePlayerFeature } from '../../feature';
import { hasMetadata } from '../../media/predicate';
export const timeFeature = definePlayerFeature({
state: ({ task }) => ({
/** Current playback position in seconds. */
currentTime: 0,
/** Total duration in seconds (0 if unknown). */
duration: 0,
state: ({ target, signal }) => {
let abort: AbortController | null = null;
/** Seek to a time in seconds. Returns the requested time. */
seek(time: number) {
return task({
key: 'seek',
async handler({ target, signal }) {
target.media.currentTime = time;
await onEvent(target.media, 'seeked', { signal });
return target.media.currentTime; // actual position after seek
},
});
},
}),
const supersede = () => {
abort?.abort();
abort = new AbortController();
return AbortSignal.any([signal(), abort.signal]);
};
return {
/** Current playback position in seconds. */
currentTime: 0,
/** Total duration in seconds (0 if unknown). */
duration: 0,
/** Whether a seek operation is in progress. */
seeking: false,
/** Seek to a time in seconds. Returns the actual position after seek. */
async seek(time: number) {
const { media } = target(),
signal = supersede();
// If metadata isn't loaded, wait for it before seeking to avoid errors.
if (!hasMetadata(media)) {
const loaded = await onEvent(media, 'loadedmetadata', { signal }).catch(() => false);
if (!loaded) return media.currentTime;
}
// Perform the seek and wait for it to complete.
const clampedTime = Math.max(0, Math.min(time, media.duration || Infinity));
media.currentTime = clampedTime;
await onEvent(media, 'seeked', { signal }).catch(noop);
return media.currentTime;
},
};
},
attach({ target, signal, set }) {
const { media } = target;
@@ -29,13 +48,15 @@ export const timeFeature = definePlayerFeature({
const sync = () =>
set({
currentTime: media.currentTime,
duration: media.duration || 0,
duration: Number.isFinite(media.duration) ? media.duration : 0,
seeking: media.seeking,
});
sync();
listen(media, 'timeupdate', sync, { signal });
listen(media, 'durationchange', sync, { signal });
listen(media, 'seeking', sync, { signal });
listen(media, 'seeked', sync, { signal });
listen(media, 'loadedmetadata', sync, { signal });
listen(media, 'emptied', sync, { signal });
+7 -16
View File
@@ -5,7 +5,7 @@ import { definePlayerFeature } from '../../feature';
import type { FeatureAvailability } from '../../types';
export const volumeFeature = definePlayerFeature({
state: ({ task }) => ({
state: ({ target }) => ({
/** Volume level from 0 (silent) to 1 (max). */
volume: 1,
/** Whether audio is muted. */
@@ -15,24 +15,16 @@ export const volumeFeature = definePlayerFeature({
/** Set volume (clamped 0-1). Returns the clamped value. */
changeVolume(volume: number) {
return task({
key: 'volume',
handler({ target }) {
target.media.volume = Math.max(0, Math.min(1, volume));
return target.media.volume;
},
});
const { media } = target();
media.volume = Math.max(0, Math.min(1, volume));
return media.volume;
},
/** Toggle mute state. Returns new muted value. */
toggleMute() {
return task({
key: 'mute',
handler({ target }) {
target.media.muted = !target.media.muted;
return target.media.muted;
},
});
const { media } = target();
media.muted = !media.muted;
return media.muted;
},
}),
@@ -42,7 +34,6 @@ export const volumeFeature = definePlayerFeature({
set({ volumeAvailability: canSetVolume() });
const sync = () => set({ volume: media.volume, muted: media.muted });
sync();
listen(media, 'volumechange', sync, { signal });