diff --git a/.claude/plans/store/bindings.md b/.claude/plans/store/bindings.md
index d11ab290..c0f279e6 100644
--- a/.claude/plans/store/bindings.md
+++ b/.claude/plans/store/bindings.md
@@ -11,7 +11,7 @@ Implemented React and Lit bindings for `@videojs/store`: `createStore()` factory
| Phase | PR | Description |
| ----- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------ |
| 0 | [#283](https://github.com/videojs/v10/pull/283) | `uniqBy`, `composeCallbacks`, `extendConfig` utilities |
-| 0.5 | [#287](https://github.com/videojs/v10/pull/287) | Queue task refactor |
+| 0.5 | [#287](https://github.com/videojs/v10/pull/287) | Queue task refactor (later removed) |
| 1 | [#288](https://github.com/videojs/v10/pull/288) | React bindings |
| 2 | [#289](https://github.com/videojs/v10/pull/289) | Lit bindings |
| 3 | [#292](https://github.com/videojs/v10/pull/292) | DOM media slices |
diff --git a/.claude/plans/store/store-v2.md b/.claude/plans/store/store-v2.md
index 31a5db78..856ee3cf 100644
--- a/.claude/plans/store/store-v2.md
+++ b/.claude/plans/store/store-v2.md
@@ -2,11 +2,22 @@
Simplify store package by removing queue public API, simplifying internals, and cleaning up bindings.
-**Note:** RequestMeta stays in implementation, removed from types only where task.ts removal requires it.
-
---
-## Phase 1: Queue Simplification
+## Phase 1: Queue Removal ✅
+
+> **COMPLETED:** Queue, task, and meta were completely removed. Actions now use `target()` directly.
+
+**What was done:**
+- Deleted `queue.ts`, `request.ts` and their tests
+- Removed `Task`, `TaskOptions`, `TaskHandler`, `TaskContext` types from `slice.ts`
+- Simplified `StateContext` to just `{ target: () => Target }`
+- Removed `pending`, `meta()`, `onTaskStart`, `onTaskEnd` from store
+- Removed `ABORTED`, `SUPERSEDED` error codes
+- Actions now call `target()` directly instead of using `task()` wrapper
+
+
+Original plan (obsolete)
Remove public API, keep internal for abort/supersession/modes.
@@ -324,6 +335,8 @@ pnpm lint
pnpm build:packages
```
+
+
---
## Phase 2: Bindings Cleanup (Separate PR)
diff --git a/.claude/skills/docs/SKILL.md b/.claude/skills/docs/SKILL.md
index 92a88e8f..fd59e714 100644
--- a/.claude/skills/docs/SKILL.md
+++ b/.claude/skills/docs/SKILL.md
@@ -121,8 +121,8 @@ Show why something is better:
// ❌ Don't — mutate directly
video.volume = 0.5; // No coordination, no error handling
-// ✅ Do — use requests
-await player.request.setVolume(0.5); // Queued, cancellable, tracked
+// ✅ Do — use store actions
+player.setVolume(0.5); // Managed, observable, integrated with store
```
## Familiar Terms
diff --git a/.claude/skills/git/references/branch.md b/.claude/skills/git/references/branch.md
index f76a4575..1ca741e7 100644
--- a/.claude/skills/git/references/branch.md
+++ b/.claude/skills/git/references/branch.md
@@ -18,13 +18,13 @@ type/short-description
| `feat/volume-slider` | New volume slider component |
| `feat/media-queries` | Add media query support |
| `fix/slider-drag-edge` | Fix edge case in slider dragging |
-| `fix/race-condition-queue` | Fix race condition in queue |
+| `fix/state-sync-race` | Fix race condition in state sync |
| `refactor/store-cleanup` | Clean up store internals |
| `chore/bump-deps` | Dependency updates |
| `docs/readme-examples` | Update README examples |
| `test/slider-keyboard` | Add keyboard tests for slider |
| `rfc/request-api` | RFC for new request API design |
-| `design/queue-design` | Design doc for queue architecture|
+| `design/slice-store` | Design doc for slice/store arch |
| `plan/store-simplification` | Planning store architecture |
## Guidelines
diff --git a/.claude/skills/git/references/commit.md b/.claude/skills/git/references/commit.md
index 270d3a73..2f115e57 100644
--- a/.claude/skills/git/references/commit.md
+++ b/.claude/skills/git/references/commit.md
@@ -42,7 +42,7 @@ The `!` signals breaking changes in the changelog.
```
feat(html): add volume slider component
-fix(store): prevent race condition in queue
+fix(store): prevent race condition in state sync
chore(root): bump vitest to v3
docs(core): document request lifecycle
refactor(utils): simplify event listener cleanup
diff --git a/.claude/skills/git/references/pr.md b/.claude/skills/git/references/pr.md
index f8165b22..9daf6eb5 100644
--- a/.claude/skills/git/references/pr.md
+++ b/.claude/skills/git/references/pr.md
@@ -104,8 +104,8 @@ Fix race condition where rapid play/pause could leave player in inconsistent sta
## Changes
-- Queue play/pause requests to prevent overlapping operations
-- Add guard against redundant state transitions
+- Add guard to debounce rapid play/pause calls
+- Prevent redundant state transitions
## Testing
diff --git a/.claude/skills/git/references/scope.md b/.claude/skills/git/references/scope.md
index c9abc26d..b47e632a 100644
--- a/.claude/skills/git/references/scope.md
+++ b/.claude/skills/git/references/scope.md
@@ -50,7 +50,7 @@ packages/html/src/slider.ts
packages/html/src/button.ts → html
# Cross-package refactor
-packages/store/src/queue.ts
+packages/store/src/slice.ts
packages/core/src/media.ts → packages (or primary one)
# Root configs
diff --git a/CLAUDE.md b/CLAUDE.md
index f8cc437c..c513d032 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -266,22 +266,22 @@ function isStoreError(value: unknown): value is StoreError {
Use symbols to identify objects when `instanceof` isn't reliable (e.g., cross-realm, serialization boundaries):
```ts
-const QUEUE_SYMBOL = Symbol('@videojs/queue');
+const STORE_SYMBOL = Symbol('@videojs/store');
-interface Queue {
- [QUEUE_SYMBOL]: true;
+interface Store {
+ [STORE_SYMBOL]: true;
// ...
}
-function createQueue(): Queue {
+function createStore(): Store {
return {
- [QUEUE_SYMBOL]: true,
+ [STORE_SYMBOL]: true,
// ...
};
}
-function isQueue(value: unknown): value is Queue {
- return isObject(value) && QUEUE_SYMBOL in value;
+function isStore(value: unknown): value is Store {
+ return isObject(value) && STORE_SYMBOL in value;
}
```
@@ -346,7 +346,7 @@ import { Disposer } from '@videojs/utils/events';
connect(): void {
this.#disposer.add(store.subscribe(...));
- this.#disposer.add(queue.subscribe(...));
+ this.#disposer.add(listen(element, 'click', handler));
}
disconnect(): void {
diff --git a/internal/design/queue-design.md b/internal/design/archive/queue-design.md
similarity index 100%
rename from internal/design/queue-design.md
rename to internal/design/archive/queue-design.md
diff --git a/packages/core/src/dom/media/predicate.ts b/packages/core/src/dom/media/predicate.ts
new file mode 100644
index 00000000..5cb0b0bf
--- /dev/null
+++ b/packages/core/src/dom/media/predicate.ts
@@ -0,0 +1,3 @@
+export function hasMetadata(media: HTMLMediaElement): boolean {
+ return media.readyState >= HTMLMediaElement.HAVE_METADATA;
+}
diff --git a/packages/core/src/dom/store/features/playback.ts b/packages/core/src/dom/store/features/playback.ts
index 76e295e5..5ac1b161 100644
--- a/packages/core/src/dom/store/features/playback.ts
+++ b/packages/core/src/dom/store/features/playback.ts
@@ -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();
},
}),
diff --git a/packages/core/src/dom/store/features/source.ts b/packages/core/src/dom/store/features/source.ts
index 0e6b515d..ff81757f 100644
--- a/packages/core/src/dom/store/features/source.ts
+++ b/packages/core/src/dom/store/features/source.ts
@@ -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;
},
}),
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 2f6a1695..0fd6c9bb 100644
--- a/packages/core/src/dom/store/features/tests/source.test.ts
+++ b/packages/core/src/dom/store/features/tests/source.test.ts
@@ -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()(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();
+ });
});
});
});
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 ed20e681..da9fd419 100644
--- a/packages/core/src/dom/store/features/tests/time.test.ts
+++ b/packages/core/src/dom/store/features/tests/time.test.ts
@@ -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()(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()(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;
}
diff --git a/packages/core/src/dom/store/features/time.ts b/packages/core/src/dom/store/features/time.ts
index 3f3182de..47cc99dd 100644
--- a/packages/core/src/dom/store/features/time.ts
+++ b/packages/core/src/dom/store/features/time.ts
@@ -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 });
diff --git a/packages/core/src/dom/store/features/volume.ts b/packages/core/src/dom/store/features/volume.ts
index b560f9f7..3ac43dec 100644
--- a/packages/core/src/dom/store/features/volume.ts
+++ b/packages/core/src/dom/store/features/volume.ts
@@ -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 });
diff --git a/packages/store/README.md b/packages/store/README.md
index 46ae6172..c4d7a44a 100644
--- a/packages/store/README.md
+++ b/packages/store/README.md
@@ -62,7 +62,7 @@ const volumeSlice = defineSlice()({
muted: false,
// Sync - use target() directly
- setVolume(value: number) {
+ setVolume() {
target().volume = Math.max(0, Math.min(1, value));
},
diff --git a/packages/store/src/core/config.ts b/packages/store/src/core/config.ts
index c5464c40..99ff7250 100644
--- a/packages/store/src/core/config.ts
+++ b/packages/store/src/core/config.ts
@@ -1,19 +1,9 @@
-import type { TaskKey } from './queue';
-import type { RequestMeta } from './request';
import type { Store } from './store';
-export interface PendingTask {
- key: TaskKey;
- meta: RequestMeta | null;
- startedAt: number;
-}
-
export interface StoreCallbacks {
onSetup?: (ctx: StoreSetupContext) => void;
onAttach?: (ctx: StoreAttachContext) => void;
onError?: (ctx: StoreErrorContext) => void;
- onTaskStart?: (ctx: StoreTaskContext) => void;
- onTaskEnd?: (ctx: StoreTaskContext & { error?: unknown }) => void;
}
export interface StoreSetupContext {
@@ -31,8 +21,3 @@ export interface StoreErrorContext {
store: Store;
error: unknown;
}
-
-export interface StoreTaskContext {
- key: TaskKey;
- meta: RequestMeta | null;
-}
diff --git a/packages/store/src/core/errors.ts b/packages/store/src/core/errors.ts
index ed9b14db..d6fa58f3 100644
--- a/packages/store/src/core/errors.ts
+++ b/packages/store/src/core/errors.ts
@@ -1,29 +1,8 @@
-/**
- * Error codes for store operations.
- *
- * @example
- * ```ts
- * if (isStoreError(error)) {
- * switch (error.code) {
- * case 'SUPERSEDED':
- * // Request was replaced by another - expected behavior
- * break;
- * case 'ABORTED':
- * // Request was aborted
- * break;
- * }
- * }
- * ```
- */
export type StoreErrorCode =
- /** Request was aborted via AbortSignal. */
- | 'ABORTED'
- /** Store or queue was destroyed. */
+ /** Store was destroyed. */
| 'DESTROYED'
/** No target is attached to the store. */
- | 'NO_TARGET'
- /** Request was replaced by a newer request with the same key. */
- | 'SUPERSEDED';
+ | 'NO_TARGET';
export interface StoreErrorOptions {
cause?: unknown;
@@ -45,3 +24,11 @@ export class StoreError extends Error {
export function isStoreError(error: unknown): error is StoreError {
return error instanceof StoreError;
}
+
+export function throwNoTargetError(): never {
+ throw new StoreError('NO_TARGET');
+}
+
+export function throwDestroyedError(): never {
+ throw new StoreError('DESTROYED');
+}
diff --git a/packages/store/src/core/index.ts b/packages/store/src/core/index.ts
index 01abb6d0..f506ec56 100644
--- a/packages/store/src/core/index.ts
+++ b/packages/store/src/core/index.ts
@@ -1,9 +1,6 @@
export { combine } from './combine';
export * from './config';
export * from './errors';
-export type { TaskKey, TaskMode } from './queue';
-export { CANCEL_ALL } from './queue';
-export * from './request';
export { createSelector } from './selector';
export type { Comparator, Selector } from './shallow-equal';
export { shallowEqual } from './shallow-equal';
diff --git a/packages/store/src/core/queue.ts b/packages/store/src/core/queue.ts
deleted file mode 100644
index 4c320e2e..00000000
--- a/packages/store/src/core/queue.ts
+++ /dev/null
@@ -1,83 +0,0 @@
-import { abortable } from '@videojs/utils/events';
-import { StoreError } from './errors';
-
-/** Cancel all pending tasks (nuclear reset). */
-export const CANCEL_ALL = Symbol.for('@videojs/cancel-all');
-
-// ----------------------------------------
-// Types
-// ----------------------------------------
-
-export type TaskKey = string | symbol;
-
-export type TaskMode = 'exclusive' | 'shared';
-
-export interface QueueTask