@insession/extension-watch-party
v0.5.1
Published
A dependency-free, framework-agnostic Watch Party state machine: server-authoritative synchronized video/audio playback with a queue and history. reduce() is a pure function that returns the next state plus effect descriptors for the host to execute.
Downloads
2,863
Maintainers
Readme
@insession/extension-watch-party
A dependency-free, server-authoritative Watch Party state machine: one shared "now playing" item (a YouTube video or a SoundCloud track), a queue, and a play history — synchronized across everyone in a space.
Building "everyone watches the same video, in sync" is deceptively fiddly: broadcasting a position every second wastes bandwidth and still drifts, a queue add can lose the send order the moment one lookup is slower than the next, and "the video ended" arrives from every client at once and has to be handled exactly once. This package is that state machine, with none of the plumbing:
reduceis a pure function.(state, action, payload) => { state, effects } | null. No I/O —nullmeans "ignore this action" (invalid payload, or a genuine no-op like an emptyseek).- Side effects are descriptors, not callbacks. Instead of taking a
broadcast/persist/fetchTitlefunction and calling it internally,reducereturns a list of typedWatchPartyEffectobjects describing what should happen. Your host interprets and executes them. This keepsreducefully synchronous and trivially testable — see Effects below. - No wall-clock ticking. Position is derived on demand
(
currentPosition) by extrapolating from the last known position and timestamp while playing. The server only needs to react to actual play/pause/seek/load events (plus an occasionalrequest-syncfor a client catching up after being backgrounded). - Every input is capped and validated. Video/track ids, queue length, per-member queue limits, title/URL lengths — all bounded, so a malicious or buggy client can't grow shared state without limit or smuggle garbage into a broadcast.
restoreis defensive by design. Feed it whatever your storage layer handed back — even malformed JSON — and it returns a safe state with caps applied. Playback always comes back stopped (not resumed against a clock that's no longer valid after a restart).
What this package deliberately does not do
This is the synchronization layer, not a media client. It has no dependencies, including no HTTP client, so it cannot and does not:
- Search for videos/tracks, resolve oEmbed titles, fetch durations, or talk
to any provider's API. When a title/duration is unknown,
reduceemits aresolve-metadataeffect asking your host to go find it — see Resolving titles and durations. - Persist anything to a database, or send anything over a WebSocket (or any other transport). It only describes those actions as effects.
- Validate that a submitted
mediaUrl/thumbnailpoints somewhere trusted (e.g. an allow-listed SoundCloud host). That's provider-specific policy this package doesn't own — validate it yourself before the payload reachesreduce. - Pick a candidate for you when a shared "auto-DJ" / "mix" feature is active. It only guarantees such a feature can take priority over the queue without this package interfering — see Deferring to a host-owned "mix" feature.
Install
npm install @insession/extension-watch-partyPublished as a built package with both ESM (dist/index.js) and CommonJS
(dist/index.cjs) entry points plus dist/index.d.ts types, no runtime
dependencies.
Drop it into a space
If you are assembling a space with
@insession/space, the whole
integration is one line — the extension carries its own name, reducer and
persistence rules, and its effects arrive tagged with their origin:
import { createSpace } from '@insession/space';
import { watchPartyExtension } from '@insession/extension-watch-party';
const space = createSpace({ extensions: [watchPartyExtension({ pickShuffleIndex })] });
space.dispatch('watch-party', 'load-video', { videoId, by: name });
// -> [broadcast, { type: 'extension', extension: 'watch-party', effect: { type: 'persist-playback', ... } }, clear-timer]Every option createWatchParty takes is accepted here too, plus { name } to
occupy a different key.
Nothing is imported from @insession/space to build that object: it satisfies
that package's SpaceExtension structurally, so this package keeps its zero
dependencies and everything below still works without it.
Usage
import {
createWatchParty,
type WatchPartyEffect,
type WatchPartyState,
} from '@insession/extension-watch-party';
const watchParty = createWatchParty({
// Optional: how to pick a candidate when shuffle is on. Omit it and
// shuffle is inert (always plays FIFO). See "Shuffle" below.
pickShuffleIndex: (items, currentVideoId) =>
Math.floor(Math.random() * items.length),
});
// Somewhere you keep one WatchPartyState per space, e.g. a Map<spaceId, WatchPartyState>.
let state: WatchPartyState = watchParty.defaultState();
// A client action arrives over your transport (WebSocket, etc). `by`/`addedBy`
// identify the acting member; it's your call how you derive them (session, auth, ...).
function onClientAction(action: string, payload: unknown) {
const result = watchParty.reduce(state, action, payload as Record<string, unknown>);
if (!result) return; // invalid or a no-op — nothing changed, nothing to do
state = result.state;
for (const effect of result.effects) runEffect(effect);
}
function runEffect(effect: WatchPartyEffect) {
switch (effect.type) {
case 'broadcast':
broadcastToSpace(effect.message, { excludeSender: effect.excludeSender });
break;
case 'send-to-sender':
sendToSender(effect.message);
break;
case 'persist-playback':
db.savePlaybackState(spaceId, effect.videoId, effect.isPlaying, effect.position).catch(() => {});
break;
case 'persist-media':
db.saveMedia(spaceId, effect.provider, effect.mediaUrl, effect.thumbnail).catch(() => {});
break;
case 'resolve-metadata':
// Go fetch a title/duration however you like (oEmbed, a provider API,
// a cache, ...), then feed the result back in — see below.
resolveTitleAndDuration(effect).then(({ title, durationSec }) => {
const patched = watchParty.reduce(state, 'resolve-metadata', {
uid: effect.uid,
kind: effect.kind,
title,
durationSec,
});
if (!patched) return;
state = patched.state;
for (const e of patched.effects) runEffect(e);
});
break;
}
}
// Load from storage on space startup / first join.
function loadFromDb(raw: unknown) {
state = watchParty.restore(raw) ?? watchParty.defaultState();
}Actions
reduce(state, action, payload) accepts these action strings:
| Action | Payload | Effect |
| --- | --- | --- |
| load-video | { videoId, provider?, mediaUrl?, thumbnail?, title?, durationSec?, by? } | Loads and plays an item immediately (position 0). Records a history entry. |
| play | { position?, by? } | Resumes playback. An invalid/missing position keeps the current (extrapolated) position rather than rewinding to 0. |
| pause | — | Always a no-op. See Why pause does nothing. |
| seek | { position, by? } | Jumps to position. An invalid position does nothing at all (no broadcast, no persistence) — there's no safe fallback for a garbage seek target. |
| video-ended | { videoId, shuffleEnabled?, mixActive? } | Reported by every client; only honored if videoId matches the current item. Advances the queue, defers entirely if mixActive, or freezes playback if there's nothing next. See Deferring to a host-owned "mix" feature. |
| request-sync | — | No state change. Emits a send-to-sender effect with the current position, for a client that needs to catch up immediately. |
| queue-add | { videoId, provider?, mediaUrl?, thumbnail?, title?, durationSec?, addedBy?, addedByUid?, uid?, addSeq?, maxQueueLength?, maxPerUser?, maxDurationSec?, shuffleEnabled? } | Adds to the queue in send order (see addSeq below). Pass uid to give the item your own id (see below). Auto-plays if nothing is currently playing. |
| queue-remove | { uid } | Removes one item by id. No-op if not found. |
| queue-clear | — | Empties the queue. No-op if already empty. |
| queue-reorder | { uid, toIndex, shuffleEnabled? } | Moves an item. Ignored entirely while shuffleEnabled (reordering has no meaning when play order isn't array order). |
| queue-play | { uid, by?, byUid? } | Plays a specific queued item immediately, regardless of position. |
| queue-play-next | { by?, byUid?, shuffleEnabled? } | Advances the queue (FIFO, or via pickShuffleIndex when shuffleEnabled). No-op on an empty queue. |
| resolve-metadata | { uid, kind?, title?, durationSec? } | Applies a host-resolved title/duration to the queue (kind: 'queue', the default) or history (kind: 'history') item with that uid. Never overwrites a field that's already known. No-op if the item is gone (already played/removed/aged out). |
Any other action string returns null. Because the payload arrives over
the wire, every field is treated as untrusted and validated at the point of
use — reduce never throws on malformed input.
A few payload fields are host-trusted settings, not wire data: shuffleEnabled,
mixActive, maxQueueLength, maxPerUser, and maxDurationSec. Your host is
expected to read these from its own space settings and fold them into the
payload before calling reduce — the same way it's expected to derive by/
addedBy from an authenticated session rather than trust a client-submitted name.
Effects
reduce never performs I/O. It returns { state, effects }, where effects
is a list of WatchPartyEffect descriptors:
type WatchPartyEffect =
| { type: 'broadcast'; message: unknown; excludeSender?: boolean }
| { type: 'send-to-sender'; message: unknown }
| { type: 'persist-playback'; videoId: string | null; isPlaying: boolean; position: number }
| { type: 'persist-media'; provider: WatchPartyProvider | null; mediaUrl: string | null; thumbnail: string | null }
| {
type: 'resolve-metadata';
uid: string;
kind: 'queue' | 'history';
videoId: string;
provider: WatchPartyProvider;
mediaUrl: string | null;
by: string | null;
byUid: string | null;
durationSec: number | null;
};Your host loops over result.effects and executes each one against its own
transport/storage — see the runEffect example above. This mirrors the
effect-descriptor convention @insession/space-state's reduceSpace uses:
the reducer describes what should happen, the host decides how.
broadcast/send-to-sender messages are already stripped of internal-only
fields (addedByUid, addSeq on queue items; byUid on history items) —
your host can forward effect.message straight to the wire without having to
remember to sanitize it itself.
Resolving titles and durations
This package never fetches a title or duration itself — it has no HTTP
client and no opinion on your provider's API. When queue-add or a
play-transition (load-video, queue-play, an auto-advance, ...) doesn't
already know an item's title, reduce emits a resolve-metadata effect
carrying everything you need to look it up (videoId, provider,
mediaUrl) plus a uid/kind pair identifying exactly which queue or
history entry to patch. Once you've resolved it, call reduce again with
action 'resolve-metadata' and that same uid/kind:
watchParty.reduce(state, 'resolve-metadata', {
uid: effect.uid,
kind: effect.kind, // 'queue' | 'history'
title: 'Never Gonna Give You Up',
durationSec: 213,
});This never overwrites a title/duration that's already known (in case two resolutions race, or the item finished resolving via another path), and it's a no-op if the item is gone by the time you resolve it (already played, removed, or aged out of the capped history) — matching the app this was ported from, which has the same fail-open shrug for a metadata fetch that lands after its target disappeared.
Deferring to a host-owned "mix" feature
Some hosts layer an "auto-DJ"/"mix" feature on top of Watch Party (pick a random next track from a pool, keep the party going with no manual queueing) — entirely out of scope for this package, since it usually needs an LLM, a recommendation API, or a user's library. But when a video ends, that feature needs to take priority over the plain queue, and it should never have this package's own queue-advance race against it.
video-ended's payload takes a mixActive flag for exactly this:
watchParty.reduce(state, 'video-ended', {
videoId: endedVideoId,
mixActive: yourMixFeature.hasNextTrack(mySpace),
});When mixActive is true, reduce does nothing at all — no queue
advance, no freeze, no effects. Your mix feature owns everything from that
point, including what happens if it turns out to have no candidate either.
This is a plain payload flag rather than a function injected into
createWatchParty (contrast pickShuffleIndex, which is injected at the
factory level) because whether a mix is active is dynamic, per-space state —
it can turn on or off between one video-ended call and the next. A
factory-level callback would go stale the instant that happens; your host
already knows the answer synchronously when it decides to call reduce, so
it's simplest for it to just say so.
Why pause does nothing
This is a deliberate design decision, not a stub. pause applies only to
the client that pressed it — the shared server state (isPlaying,
position) is untouched, and nothing is broadcast to anyone else. play and
seek do affect the whole space, which makes pause doing nothing look
like an inconsistency at first glance, but pausing your own player (to
answer the door, say) shouldn't stop the video for everyone else. Every other
client — including the pauser's own state on reconnect — keeps playing from
the shared position untouched. The action is accepted (so a host doesn't have
to special-case it client-side) and always returns null.
Shuffle
Watch Party's shuffle only ever avoids repeating the currently-playing item —
it's not "don't repeat until everyone's queue entry has played once." This
package doesn't implement that selection algorithm itself; you pass it in as
pickShuffleIndex when calling createWatchParty. This is deliberate: if
your app shares one shuffle implementation across Watch Party and some other
"random pick from a list" feature (so "random" means the same thing
everywhere), bundling a second implementation into this package would let the
two drift apart. If you don't need that guarantee, any function that returns
a valid index into the given items works, e.g.:
pickShuffleIndex: (items) => Math.floor(Math.random() * items.length)Omit pickShuffleIndex entirely and shuffle is simply inert — shuffleEnabled
in the payload is ignored and the queue always advances FIFO.
API
| Export | Signature | Meaning |
| --- | --- | --- |
| createWatchParty | (options?: { pickShuffleIndex?, autoAdvanceBy? }) => WatchPartyStateApi | Builds the API. Both options are optional. |
| defaultState() | () => WatchPartyState | An empty space: nothing loaded, empty queue/history. Also available as a top-level export (independent of createWatchParty's options). |
| currentPosition(state) | (state: WatchPartyState) => number | The playback position right now, extrapolated by wall clock while playing. Also available as a top-level export. |
| .reduce | (state, action, payload?) => { state, effects } \| null | Applies one action. null means "ignore" (invalid or a no-op). |
| .restore | (raw: unknown) => WatchPartyState \| null | Normalizes state loaded from storage. null only for non-object input; playback always comes back stopped. |
createWatchParty's options:
pickShuffleIndex?: (items, currentVideoId) => number— see Shuffle.autoAdvanceBy?: string— thebyvalue stamped on items that start playing without a specific acting member (an empty space's firstqueue-add, orvideo-ended's queue-advance). Defaults to'queue'. Lets your UI distinguish "the queue advanced this on its own" from "a member pressed play" — pass your own value if you want one distinct from real member names.
Types
WatchPartyState, WatchPartyProvider, WatchPartyQueueItem,
WatchPartyHistoryItem, WatchPartyAction, WatchPartyPayload,
WatchPartyEffect, CreateWatchPartyOptions, and WatchPartyStateApi are
all exported. reduce's action parameter is typed as string rather than
WatchPartyAction on purpose — it sits at a wire boundary where the action
name is untrusted input, and anything outside the known set falls through to
null.
Bring your own item ids if you persist the queue
Queue items get a counter-based uid by default (q1, q2, ...). That is only
safe while the whole queue lives in memory: the counter restarts at zero when
you reload a stored queue, so restored items and freshly added ones end up
sharing ids.
If you persist the queue, pass the id you store it under:
const uid = crypto.randomUUID(); // or your database's primary key
watchParty.reduce(state, 'queue-add', { videoId, uid });
// later: reduce(state, 'queue-remove', { uid }) targets the same rowThe same uid is what queue-remove / queue-reorder / queue-play take, so
your storage and this state machine stay pointed at the same item.
Keeping send order when you resolve metadata first
If your host awaits anything before calling reduce — looking up a duration to
enforce a cap, fetching a title — two adds sent back to back can land in the
opposite order, because whichever lookup finishes first gets to reduce first.
The queue would then show them swapped relative to what people actually sent.
Stamp arrival order before you await, and hand it in as addSeq:
// Capture arrival order synchronously, before any await.
seq += 1;
const addSeq = seq;
const durationSec = await lookUpDuration(videoId); // lands out of order
const out = watchParty.reduce(state, 'queue-add', { videoId, durationSec, addSeq });reduce slots the item in by addSeq rather than appending, so send order
survives. Omit addSeq and it assigns one at call time — fine for a host that
never awaits first.
Test
node --testEvery test either uses inputs with no time dependency or freezes Date.now()
for its duration, so the suite is fully deterministic — no real clocks, no
wall-clock waits.
License
MIT
