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