diff --git a/packages/core/src/core/media/state.ts b/packages/core/src/core/media/state.ts index d3d32074..af2286b7 100644 --- a/packages/core/src/core/media/state.ts +++ b/packages/core/src/core/media/state.ts @@ -206,6 +206,32 @@ export interface MediaTextTrackState { thumbnailTrackSrc: string | null; } +export interface MediaError { + /** + * The error code (mirrors MediaError.code constants). + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/MediaError/code + */ + code: number; + /** + * A human-readable error message. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/MediaError/message + */ + message: string; +} + +export interface MediaErrorState { + /** + * The current media error, or null if none. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/error + */ + error: MediaError | null; + /** Dismiss the current error by clearing it. */ + dismissError(): void; +} + export interface MediaPictureInPictureState { /** * Whether picture-in-picture mode is currently active. diff --git a/packages/core/src/dom/media/types.ts b/packages/core/src/dom/media/types.ts index 4a7b521c..e3cbde0e 100644 --- a/packages/core/src/dom/media/types.ts +++ b/packages/core/src/dom/media/types.ts @@ -2,6 +2,7 @@ import type { AnySlice, Slice, Store, UnionSliceState } from '@videojs/store'; import type { MediaBufferState, MediaControlsState, + MediaErrorState, MediaFullscreenState, MediaPictureInPictureState, MediaPlaybackRateState, @@ -55,6 +56,7 @@ export type VideoFeatures = [ PlayerFeature, PlayerFeature, PlayerFeature, + PlayerFeature, ]; export type AudioFeatures = [ diff --git a/packages/core/src/dom/store/features/error.ts b/packages/core/src/dom/store/features/error.ts new file mode 100644 index 00000000..18841549 --- /dev/null +++ b/packages/core/src/dom/store/features/error.ts @@ -0,0 +1,25 @@ +import { listen } from '@videojs/utils/dom'; + +import type { MediaErrorState } from '../../../core/media/state'; +import { definePlayerFeature } from '../../feature'; + +export const errorFeature = definePlayerFeature({ + name: 'error', + state: ({ set }): MediaErrorState => ({ + error: null, + dismissError() { + set({ error: null }); + }, + }), + + attach({ target, signal, set }) { + const { media } = target; + + const syncError = () => set({ error: media.error }); + + listen(media, 'error', syncError, { signal }); + + // Reset error state when a new source is loaded. + listen(media, 'emptied', () => set({ error: null }), { signal }); + }, +}); diff --git a/packages/core/src/dom/store/features/index.ts b/packages/core/src/dom/store/features/index.ts index 9c7b3cb5..2bbb08f7 100644 --- a/packages/core/src/dom/store/features/index.ts +++ b/packages/core/src/dom/store/features/index.ts @@ -1,5 +1,6 @@ export * from './buffer'; export * from './controls'; +export * from './error'; export * as features from './feature.parts'; export * from './fullscreen'; export * from './pip'; diff --git a/packages/core/src/dom/store/features/presets.ts b/packages/core/src/dom/store/features/presets.ts index 62d1064c..ef1e77d4 100644 --- a/packages/core/src/dom/store/features/presets.ts +++ b/packages/core/src/dom/store/features/presets.ts @@ -1,6 +1,7 @@ import type { AudioFeatures, BackgroundFeatures, VideoFeatures } from '../../media/types'; import { bufferFeature } from './buffer'; import { controlsFeature } from './controls'; +import { errorFeature } from './error'; import { fullscreenFeature } from './fullscreen'; import { pipFeature } from './pip'; import { playbackFeature } from './playback'; @@ -21,6 +22,7 @@ export const videoFeatures: VideoFeatures = [ pipFeature, controlsFeature, textTrackFeature, + errorFeature, ]; export const audioFeatures: AudioFeatures = [ diff --git a/packages/core/src/dom/store/selectors.ts b/packages/core/src/dom/store/selectors.ts index 8eea737d..040a7672 100644 --- a/packages/core/src/dom/store/selectors.ts +++ b/packages/core/src/dom/store/selectors.ts @@ -2,6 +2,7 @@ import { createSelector } from '@videojs/store'; import { bufferFeature } from './features/buffer'; import { controlsFeature } from './features/controls'; +import { errorFeature } from './features/error'; import { fullscreenFeature } from './features/fullscreen'; import { pipFeature } from './features/pip'; import { playbackFeature } from './features/playback'; @@ -15,6 +16,8 @@ import { volumeFeature } from './features/volume'; export const selectBuffer = createSelector(bufferFeature); /** Select the controls state (controls visible, user-active). */ export const selectControls = createSelector(controlsFeature); +/** Select the error state (error, dismissed, dismissError). */ +export const selectError = createSelector(errorFeature); /** Select the fullscreen state (fullscreen active, availability). */ export const selectFullscreen = createSelector(fullscreenFeature); /** Select the PiP state (picture-in-picture active, availability). */ diff --git a/packages/store/src/core/selector.ts b/packages/store/src/core/selector.ts index 3a9937fb..99ed4e39 100644 --- a/packages/store/src/core/selector.ts +++ b/packages/store/src/core/selector.ts @@ -7,6 +7,7 @@ import type { AnySlice, InferSliceState, StateContext } from './slice'; const stateContext: StateContext = { target: throwNoTargetError, signals: new AbortControllerRegistry(), + set: throwNoTargetError, }; /** diff --git a/packages/store/src/core/slice.ts b/packages/store/src/core/slice.ts index 851c1e13..fe9d8831 100644 --- a/packages/store/src/core/slice.ts +++ b/packages/store/src/core/slice.ts @@ -40,6 +40,8 @@ export interface StateContext { * (e.g., loading a new source cancels pending seeks). */ signals: AbortControllerRegistry; + /** Patch the slice state. Safe to use inside action closures (not during `state()` init). */ + set: (partial: Record) => void; } // ---------------------------------------- diff --git a/packages/store/src/core/store.ts b/packages/store/src/core/store.ts index 773bf604..819da733 100644 --- a/packages/store/src/core/store.ts +++ b/packages/store/src/core/store.ts @@ -38,6 +38,7 @@ export function createStore(): ( return target!; }, signals, + set: (partial) => state.patch(partial as Partial), } satisfies StateContext); state = createState(initialState);