Writing a plugin
Build a skip intro button, and meet each part of a plugin on the way.
Your plugins use the same API as the ones the package ships. This page builds a small one from scratch: a skip intro button that shows while the current time is inside the intro, and seeks to the end of the intro when pressed. For a real player, the skip plugin is the complete version.
Press play below. The button appears at 2 seconds, and pressing it jumps to 12.
"use client";import { useCommands, useVideoState } from "react-video-kit";import { definePlugin } from "react-video-kit/core";import { VideoPlayer } from "react-video-kit/player";interface SkipIntroOptions { /** Where the intro starts, in seconds. */ start: number; /** Where the intro ends, and where the button seeks to. */ end: number;}/** A button that shows during the intro, and a command that jumps past it. */const skipIntro = definePlugin("skip-intro", ({ start, end }: SkipIntroOptions) => ({ state: () => ({ inIntro: false }), commands: (context) => ({ skip: () => context.commands.seek(end), }), setup(context, scope) { const check = (time: number) => context.set({ inIntro: time >= start && time < end }); scope.add(context.clock.bind(check, () => 0.25)); }, slots: { overlay: SkipIntroButton },}));function SkipIntroButton() { const visible = useVideoState(skipIntro, (state, media) => state.inIntro && media.started); const { skip } = useCommands(skipIntro); if (!visible) return null; return ( <button type="button" onClick={() => skip()} className="absolute right-4 bottom-24 z-10 rounded-lg bg-black/75 px-4 py-2 text-sm font-medium text-white" > Skip intro </button> );}const plugins = [skipIntro({ start: 2, end: 12 })];export default function CustomPlugin() { return <VideoPlayer src="/media/sample-720.mp4" poster="/media/poster.jpg" plugins={plugins} load="visible" />;}The rest of this page goes through that file one piece at a time.
Define the plugin and its options
definePlugin takes a name and a function that returns the plugin's spec. That function's parameters become the plugin's options. The spec is empty for now.
import { definePlugin } from "react-video-kit/core";interface SkipIntroOptions { start: number; end: number;}export const skipIntro = definePlugin("skip-intro", ({ start, end }: SkipIntroOptions) => ({}));Calling the result creates an instance for the plugins prop:
const plugins = [skipIntro({ start: 2, end: 12 })];- The function runs once for each player, so anything it declares stays private to that player, and one instance can be shared between players.
- A player can only install a name once, and
mediais reserved.
Add state and commands
state returns the plugin's initial state. commands receives the plugin's context and returns what the plugin can do. TypeScript infers both.
export const skipIntro = definePlugin("skip-intro", ({ end }: SkipIntroOptions) => ({ state: () => ({ inIntro: false }), commands: (context) => ({ skip: () => context.commands.seek(end), }),}));context.commands holds the player's own commands, like seek and play. context.state() reads this plugin's state, context.set() merges into it, and context.media() reads the player's media state.
State holds inIntro, not the time. Time changes many times a second and stays out of the store, while this boolean only changes on the way in and on the way out.
Read it from a component
useVideoState and useCommands take the plugin's factory, and get their types from it:
import { useCommands, useVideoState } from "react-video-kit";function SkipIntroButton() { const visible = useVideoState(skipIntro, (state, media) => state.inIntro && media.started); const { skip } = useCommands(skipIntro); if (!visible) return null; return ( <button type="button" onClick={() => skip()}> Skip intro </button> );}- The selector gets the plugin's state and the media state, and the component renders again only when the result changes.
media.startedkeeps the button off the poster. Before the source loads, the timeline is empty, andseekhas nowhere to go.- Both hooks throw an error naming the plugin if it isn't installed.
Outside the player, player.plugin(skipIntro) gives the same typed access from a ref.
Put it in a slot
slots gives the component a place in the layout:
export const skipIntro = definePlugin("skip-intro", ({ end }: SkipIntroOptions) => ({ state: () => ({ inIntro: false }), commands: (context) => ({ skip: () => context.commands.seek(end), }), slots: { overlay: SkipIntroButton },}));A plugin gives each slot one component, which takes no props and reads what it needs through hooks. Slots lists them all. In your own layout, a slot only appears where you render <Video.Slot name="overlay" />, and you can make up new names.
overlay parts render directly inside the player's root, so they position themselves. The demo's button uses absolute, and a z-index above the controls, which sit at 2.
Run code at the right time
Three hooks run only in the browser, and each one receives a scope that ends with it:
setup(context, scope)runs from mount to unmount.attach(context, element, scope)runs while the media element is connected.source(context, source, scope)runs for each source until the next one.
The clock lasts as long as the player, so the code that sets inIntro goes in setup:
export const skipIntro = definePlugin("skip-intro", ({ start, end }: SkipIntroOptions) => ({ state: () => ({ inIntro: false }), commands: (context) => ({ skip: () => context.commands.seek(end), }), setup(context, scope) { const check = (time: number) => context.set({ inIntro: time >= start && time < end }); scope.add(context.clock.bind(check, () => 0.25)); }, slots: { overlay: SkipIntroButton },}));clock.bind(write, resolution) calls write right away, then whenever time has moved by resolution seconds, and after every seek or source change. Its second argument, jumped, is true for the calls that aren't steady playback, so a plugin that counts time watched can leave those out. Nothing runs while the video is paused. set ignores values that haven't changed, so the button renders when it appears and when it goes, and not in between.
In a component, useClock(write, resolution) does the same without rendering, and useCurrentTime(1) gives you the time as state, rendering once a second. The hooks page covers both.
Clean up with the scope
Whatever a hook starts has to end with it. React's Strict Mode mounts twice in development, so anything left behind runs twice.
scope.on(target, type, listener)adds an event listener.scope.add(cleanup)runs a function at the end.clock.bindandcontext.lockboth return one.scope.signalis anAbortSignalto pass tofetch.context.watch(scope, select, listener)callslistenerwhen part of the media state changes.
Read the element in attach
Use attach for what the player doesn't report. This plugin reads the video's shape, and attrs writes it to the player's root as a data attribute, with no React render, so CSS can use it:
export const orientation = definePlugin("orientation", () => ({ state: () => ({ portrait: false }), attrs: { "data-portrait": (state) => state.portrait }, attach(context, element, scope) { scope.on(element, "loadedmetadata", () => { context.set({ portrait: element.videoHeight > element.videoWidth }); }); },}));Change per video in source
With a playlist, each video has its own intro. The source hook picks it up, and its scope unbinds the clock when the next source starts:
interface Intro { start: number; end: number;}export const skipIntros = definePlugin("skip-intros", (intros: Record<string, Intro>) => { let intro: Intro | undefined; return { state: () => ({ inIntro: false }), commands: (context) => ({ skip() { if (intro) context.commands.seek(intro.end); }, }), source(context, source, scope) { const current = intros[source.src]; intro = current; const check = (time: number) => context.set({ inIntro: current !== undefined && time >= current.start && time < current.end }); scope.add(context.clock.bind(check, () => 0.25)); }, };});Intercept commands with gates and locks
A gate sits in front of load, play, or seek. It gets the request, can change it, and passes it on by calling next. Not calling next cancels the command.
This one stops the viewer seeking past what they've already watched, as a training video might:
export const noSkipAhead = definePlugin("no-skip-ahead", () => { let furthest = 0; return { source(context, _source, scope) { furthest = 0; const track = (time: number) => { furthest = Math.max(furthest, time); }; scope.add(context.clock.bind(track, () => 1)); }, gates: { seek: (time, next) => next(Math.min(time, furthest)), }, };});- Gates run in plugin order, and can be
async. - A
loadgate can rewrite the request. The persistence plugin adds astartTimethis way. - A
playgate that doesn't callnextmakescommands.play()resolve tofalse. - A play that starts the load runs the
sourcehooks first, so aplaygate can use what they set up. - Browsers only let playback start soon after a gesture. A
playgate runs inside that gesture up to its firstawait, and after that the browser can refuse to play.
A lock blocks commands outright until it's released. This one keeps the viewer from seeking or changing speed for the first few seconds of each video:
export const mustWatch = definePlugin("must-watch", ({ seconds }: { seconds: number }) => ({ source(context, _source, scope) { const unlock = context.lock("must-watch", ["seek", "rate"]); scope.add(unlock); const check = (time: number) => { if (time >= seconds && context.isLocked("seek")) unlock(); }; scope.add(context.clock.bind(check, () => 0.5)); },}));context.lock(id, commands)returns the function that releases it. Locks outlive a source, so add it to the scope.- A locked command does nothing. The controls react too: a
seeklock disables the timeline, aratelock hides the speed menu, and avolumelock hides the volume slider. - The player checks
play,pause,seek,rate, andvolume. Any other name is yours to check withcontext.isLocked().
Depend on other plugins
requires names plugins that must be installed, and creating the player throws an error if one is missing. after only sets the order, and does nothing when the other plugin is absent. That order applies to lifecycle hooks, gates, and slots.
import type { speed } from "react-video-kit/speed";export const startFast = definePlugin("start-fast", ({ rate }: { rate: number }) => ({ requires: ["speed"], setup(context) { context.find<typeof speed>("speed")?.commands.set(rate); },}));context.find reads another plugin by name. With import type, it adds none of that plugin's code to your bundle, which suits an optional dependency: drop requires, and find returns undefined when the plugin isn't there.
Report failures
context.error reports a failure the same way the library's own plugins do. Give it a code of your own with your plugin's prefix, and say whether it stops playback:
context.error({ code: "skip-intro-load", message: "The intro markers didn't load.", fatal: false,});A non-fatal error reaches onError and player.onError while the video keeps playing. A fatal one also shows the error overlay. Set retryable: true on a failure that can pass, so that recovery() retries it. To choose the code from a load gate or a provider, throw a VideoKitError. Errors lists every code the library uses.
Keep it light and safe on the server
- Mark the file
"use client"when it has parts or hooks.definePluginitself has no directive, so it works anywhere, but instances still have to be created in a client file. - Keep the factory,
state, andcommandspure. All three run on the server when the player is created. Touch the DOM,window, or storage only inside a lifecycle hook, or inside a command when it's called. - Match the server's first render.
stateis what the server renders and what the browser hydrates with, so to restore something fromlocalStorage, read it insetupand callcontext.set. - Give each plugin its own file. A bundler keeps a file's top-level
definePlugincalls unless the whole file is unused, so a second plugin in the same file stays in the bundle for everyone who imports the first. A plugin's own parts can share its file, sinceslotskeeps them anyway. When you publish plugins, also set"sideEffects"inpackage.json, listing only your CSS. - Import heavy dependencies with
import(), inside a hook or a command, so viewers only download them when they're used.
Types
| Field | Type | Description |
|---|---|---|
| requires? | readonly string[] | Plugins that must be installed. createPlayer throws if one is missing. |
| after? | readonly string[] | Plugins that should run first when present. |
| state? | (() => State) | Pure. Also runs on the server. |
| commands? | ((context: PluginContext<State>) => Commands) | Pure. Also runs on the server, so touch the DOM only when a command runs. |
| attrs? | Record<string, (state: State, media: MediaState) => string | number | boolean | null | undefined> | Data attributes written to Root as state changes, with no React render. |
| setup? | ((context: PluginContext<State>, scope: Scope) => void) | Runs from mount to unmount. |
| attach? | ((context: PluginContext<State>, element: HTMLVideoElement, scope: Scope) => void) | Runs from media attach to detach. |
| source? | ((context: PluginContext<State>, source: SourceObject, scope: Scope) => void) | Runs for each source until the next one. |
| gates? | Gates<State> | Hooks that can change or hold back a load, play, or seek. |
| slots? | Partial<Record<SlotName, ComponentType>> | Components rendered by <Slot name>. |
The context passed to commands, hooks, and gates:
| Field | Type | Description |
|---|---|---|
| player | Player | The player this plugin is installed on. |
| clock | Clock | The player's clock, for reading and following playback time. |
| commands | CoreCommands | The player's core commands. |
| state | () => State | Reads this plugin's current state. |
| set | (state: Partial<State>) => void | Merges changes into this plugin's state. |
| media | () => MediaState | Reads the current media state. |
| report | (state: Partial<MediaState>) => void | Writes media state, for plugins that normalize something providers can't. |
| error | (error: MediaErrorInfo) => void | Reports a failure, the way a provider does. A fatal one stops playback or moves to the next source. |
| video | () => HTMLVideoElement | null | Returns the video element, or null when none is attached. |
| root | () => HTMLElement | null | Returns the player's root element, or null when it isn't mounted. |
| watch | <Value>(scope: Scope, select: (media: MediaState) => Value, listener: (value: Value, previous: Value) => void) => void | Calls listener whenever the selected value changes, until scope ends. |
| lock | (id: string, commands: readonly LockableCommand[]) => () => void | Blocks commands until the returned function is called. |
| isLocked | (command: LockableCommand) => boolean | Returns true when a lock blocks command. |
| registerProvider | (entry: ProviderEntry) => void | Adds a provider to choose from for each source, tried before the native one. |
| pushProvider | (provider: Provider) => () => void | Hands playback to provider until the returned function is called. |
| find | <Factory extends AnyPluginFactory>(name: Factory["pluginName"]) => PluginHandle<Factory> | Reads an optional dependency without importing its code. |
The scope passed to lifecycle hooks:
| Field | Type | Description |
|---|---|---|
| signal | AbortSignal | Aborts when the scope ends, for fetch calls and the like. |
| on | <Listener extends EventListenerOrEventListenerObject>(target: EventTarget, type: string, listener: Listener, options?: AddEventListenerOptions) => void | Adds an event listener that is removed when the scope ends. |
| add | (cleanup: () => void) => void | Runs cleanup when the scope ends, or immediately if it already has. |