Add / change / remove a player event
Events are the most rule-dense surface in this repo. Every step below exists because skipping
it has broken something before. Work through the steps in order; do not improvise.
Step 0 — Classify the event
Answer one question: who emits it?
| Emitter |
Classification |
Where the type goes |
Core, StateManager, surfaces, DefaultMediaEngine, track plumbing |
Kernel event |
PlayerEventPayloadMap interface in packages/core/src/core/events.ts |
| Any other package (ads, player UI, hls, youtube, external plugins) |
Package event |
That package's src/events.ts declaration-merging augmentation |
Hard rules:
- Core's map may contain ONLY: lifecycle,
cmd:* commands, HTML5-native names (playing,
pause, ended, loadedmetadata, …), track events, source:set, player:interacted.
- If you find yourself editing
packages/core/src/core/events.ts for an ads:*, hls:*,
ui:*, or zoom:* key — stop, you are in the wrong file.
- If the emitting package is unclear (e.g. core emits it but only ads consumes it), it is a
kernel event only if core can describe the payload without importing any package concept.
Otherwise redesign so the package emits it.
Step 1 — Name and shape the payload
Naming:
cmd:<verb> — player→engine command (cmd:play, cmd:seek). Kernel only.
<pkg-prefix>:<noun>[:<phase>] — package events (ads:break:start, ui:menu:open).
- Never a bare untyped string; never dot-notation (
ads.foo was migrated away — colon only).
Payload conventions (follow existing shapes, don't invent parallel ones):
- A break descriptor is
{ id: string; kind: string } carried under a break property.
- Flat id references use
breakId (e.g. ads:quartile: { breakId, quartile: 25|50|75|100 }).
- Paired events (
X:open/X:close, X:start/X:end) must both exist and both be emitted —
the player package relies on pairing (e.g. menu open/close gates the auto-hide timer).
- Error payloads:
{ reason?, error?, message?, owner? } (see ads:error).
Step 2 — Declare the type
Kernel event: add the key to the PlayerEventPayloadMap interface in
packages/core/src/core/events.ts. Do not convert the interface to a type — it is the one
sanctioned interface (declaration merging requires it).
Package event: edit (or create) packages/<pkg>/src/events.ts:
import '@openplayerjs/core';
declare module '@openplayerjs/core' {
// eslint note: this augments core's sanctioned interface
interface PlayerEventPayloadMap {
'my:event': MyPayload;
}
}
Then verify the side-effect import exists in packages/<pkg>/src/index.ts:
import './events';
If you created events.ts and forget this import, everything compiles locally but consumers
of the published package silently lose the typing. Check it explicitly.
If the package keeps a union of its own events (ads has AdsEvent in src/types.ts), update
it in the same commit — the augmentation and the union must stay in sync.
Special case — HLS: it deliberately has NO events.ts. It drives the player through standard
core events only. Before adding an HLS event, confirm a real consumer exists (three HLS events
were removed in Jun 2026 because nothing listened). Duration/live/ID3 metadata already flow
through native channels — do not re-add events for them.
Step 3 — Emit and consume
- Emit:
ctx.events.emit('my:event', payload) — payload type is enforced.
- Subscribe in a plugin:
ctx.on('my:event', cb) (auto-disposed) — never a bare
events.on without registering the unsubscriber in ctx.dispose.
- Subscribe in a control:
this.onPlayer('my:event', cb).
- Timing rule: if the emit sits anywhere in the
Core.play() → cmd:play path, it must not
introduce an await/microtask before cmd:play fires (Safari user-gesture context).
Step 4 — Test
Minimum tests for a new event (in the owning package's __tests__/):
- Emission: drive the real trigger (not the emit itself) and assert the callback got the
exact payload shape — assert every property, not just truthiness.
- Typing: subscribing through
core.on('my:event', (p) => …) compiles with the precise
payload type (this is implicit if the test file uses the typed callback).
- Pairing/cleanup, when applicable: the paired close/end event fires; unsubscribe on destroy.
Step 5 — Document
- Add the event to the owning package's README event table (payload shape included).
- If the event encodes a behavioral rule (ordering, pairing, sync requirement), add one line
to that package's
CLAUDE.md.
Renaming or changing a payload
This is a breaking change for downstream consumers. Stop and confirm with the user
before doing it (manual §7 E1). Once approved:
- Change type + all emit sites + all subscribe sites in one commit.
grep -rn "'old:name'" packages/ until it returns nothing. If .claude/CLAUDE.local.md
lists other local consumer repos, grep and type-check those per its instructions.
- Run
pnpm run type-check.
- Commit as
feat(<scope>)!: or include a BREAKING CHANGE: footer so the release
orchestrator bumps major.
Removing an event
Never trust "no typed listener" as proof of death — tests subscribe by string.
grep -rn "'the:event'" packages/ — check src, __tests__/, e2e/, examples/.
- Same grep in any local consumer repos listed in
.claude/CLAUDE.local.md, if present.
- Only when all hits are the emit itself: delete the emit, the type entry, and the union
entry together. If the package's
events.ts becomes empty, delete the file AND its
import './events' line.
- Run the full test suite; a hanging/timing-out test means you missed a subscriber.
Final checklist
1---2name: add-event3description: Add, rename, change the payload of, or remove a typed player event in OpenPlayerJS. Use whenever a task involves PlayerEventPayloadMap, EventBus events, cmd:* commands, ads:* events, or "emit/listen to X". Encodes the core-vs-package routing decision, declaration merging, payload conventions, and the safe-removal procedure.4---56# Add / change / remove a player event78Events are the most rule-dense surface in this repo. Every step below exists because skipping9it has broken something before. Work through the steps in order; do not improvise.1011## Step 0 — Classify the event1213Answer one question: **who emits it?**1415| Emitter | Classification | Where the type goes |16| ------- | -------------- | ------------------- |17| `Core`, `StateManager`, surfaces, `DefaultMediaEngine`, track plumbing | **Kernel event** | `PlayerEventPayloadMap` interface in `packages/core/src/core/events.ts` |18| Any other package (ads, player UI, hls, youtube, external plugins) | **Package event** | That package's `src/events.ts` declaration-merging augmentation |1920Hard rules:21- Core's map may contain ONLY: lifecycle, `cmd:*` commands, HTML5-native names (`playing`,22 `pause`, `ended`, `loadedmetadata`, …), track events, `source:set`, `player:interacted`.23- If you find yourself editing `packages/core/src/core/events.ts` for an `ads:*`, `hls:*`,24 `ui:*`, or `zoom:*` key — stop, you are in the wrong file.25- If the emitting package is unclear (e.g. core emits it but only ads consumes it), it is a26 kernel event only if core can describe the payload without importing any package concept.27 Otherwise redesign so the package emits it.2829## Step 1 — Name and shape the payload3031Naming:32- `cmd:<verb>` — player→engine command (`cmd:play`, `cmd:seek`). Kernel only.33- `<pkg-prefix>:<noun>[:<phase>]` — package events (`ads:break:start`, `ui:menu:open`).34- Never a bare untyped string; never dot-notation (`ads.foo` was migrated away — colon only).3536Payload conventions (follow existing shapes, don't invent parallel ones):37- A break descriptor is `{ id: string; kind: string }` carried under a `break` property.38- Flat id references use `breakId` (e.g. `ads:quartile: { breakId, quartile: 25|50|75|100 }`).39- Paired events (`X:open`/`X:close`, `X:start`/`X:end`) must both exist and both be emitted —40 the player package relies on pairing (e.g. menu open/close gates the auto-hide timer).41- Error payloads: `{ reason?, error?, message?, owner? }` (see `ads:error`).4243## Step 2 — Declare the type4445**Kernel event:** add the key to the `PlayerEventPayloadMap` interface in46`packages/core/src/core/events.ts`. Do not convert the interface to a `type` — it is the one47sanctioned interface (declaration merging requires it).4849**Package event:** edit (or create) `packages/<pkg>/src/events.ts`:5051```ts52import '@openplayerjs/core';5354declare module '@openplayerjs/core' {55 // eslint note: this augments core's sanctioned interface56 interface PlayerEventPayloadMap {57 'my:event': MyPayload;58 }59}60```6162Then verify the side-effect import exists in `packages/<pkg>/src/index.ts`:6364```ts65import './events';66```6768If you created `events.ts` and forget this import, everything compiles locally but consumers69of the published package silently lose the typing. Check it explicitly.7071If the package keeps a union of its own events (ads has `AdsEvent` in `src/types.ts`), update72it in the same commit — the augmentation and the union must stay in sync.7374Special case — HLS: it deliberately has NO `events.ts`. It drives the player through standard75core events only. Before adding an HLS event, confirm a real consumer exists (three HLS events76were removed in Jun 2026 because nothing listened). Duration/live/ID3 metadata already flow77through native channels — do not re-add events for them.7879## Step 3 — Emit and consume8081- Emit: `ctx.events.emit('my:event', payload)` — payload type is enforced.82- Subscribe in a plugin: `ctx.on('my:event', cb)` (auto-disposed) — never a bare83 `events.on` without registering the unsubscriber in `ctx.dispose`.84- Subscribe in a control: `this.onPlayer('my:event', cb)`.85- Timing rule: if the emit sits anywhere in the `Core.play()` → `cmd:play` path, it must not86 introduce an `await`/microtask before `cmd:play` fires (Safari user-gesture context).8788## Step 4 — Test8990Minimum tests for a new event (in the owning package's `__tests__/`):911. Emission: drive the real trigger (not the emit itself) and assert the callback got the92 exact payload shape — assert every property, not just truthiness.932. Typing: subscribing through `core.on('my:event', (p) => …)` compiles with the precise94 payload type (this is implicit if the test file uses the typed callback).953. Pairing/cleanup, when applicable: the paired close/end event fires; unsubscribe on destroy.9697## Step 5 — Document9899- Add the event to the owning package's README event table (payload shape included).100- If the event encodes a behavioral rule (ordering, pairing, sync requirement), add one line101 to that package's `CLAUDE.md`.102103## Renaming or changing a payload104105This is a **breaking change** for downstream consumers. Stop and confirm with the user106before doing it (manual §7 E1). Once approved:1071. Change type + all emit sites + all subscribe sites in one commit.1082. `grep -rn "'old:name'" packages/` until it returns nothing. If `.claude/CLAUDE.local.md`109 lists other local consumer repos, grep and type-check those per its instructions.1103. Run `pnpm run type-check`.1114. Commit as `feat(<scope>)!:` or include a `BREAKING CHANGE:` footer so the release112 orchestrator bumps major.113114## Removing an event115116Never trust "no typed listener" as proof of death — tests subscribe by string.1171. `grep -rn "'the:event'" packages/` — check src, `__tests__/`, `e2e/`, `examples/`.1182. Same grep in any local consumer repos listed in `.claude/CLAUDE.local.md`, if present.1193. Only when all hits are the emit itself: delete the emit, the type entry, and the union120 entry together. If the package's `events.ts` becomes empty, delete the file AND its121 `import './events'` line.1224. Run the full test suite; a hanging/timing-out test means you missed a subscriber.123124## Final checklist125126- [ ] Type declared in the correct home (kernel map vs package augmentation)127- [ ] `import './events'` present in the package's `index.ts` (package events)128- [ ] Package event-union (e.g. `AdsEvent`) updated if one exists129- [ ] Emission test asserts the full payload shape130- [ ] README event table updated131- [ ] `pnpm run type-check && pnpm run lint && pnpm run test` green132- [ ] Breaking change? → user approved + both monorepos type-check + `!`/footer in commit