The WavezFM Room Extension API is the official client-side bridge for browser extensions and user scripts running on WavezFM room pages.
It exposes room state, live events, and a small set of user actions without requiring DOM queries, simulated clicks, or private application internals.
- Global bridge:
window.WavezFM - Current compatibility version:
"1" - Intended for browser extensions, user scripts, and page-level integrations
- Available while a WavezFM room client is active
- Supports authenticated room sessions and read-only guest previews
- Does not expose authentication credentials or private API tokens
This bridge is separate from the @wavezfm/api package. Use the Room Extension API for code running inside a WavezFM room page. Use the npm package or Room Bot API for external applications and unattended automation.
The bridge is installed when the room client starts. Extensions that run at document_start may execute before it is ready.
const api = window.WavezFM;
if (!api || api.version !== "1") {
console.warn("WavezFM room bridge is unavailable");
}For early-running scripts, wait for the bridge with a bounded timeout:
async function waitForWavezFM(timeoutMs = 10_000) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (window.WavezFM?.version === "1") {
return window.WavezFM;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
return null;
}
const api = await waitForWavezFM();
if (!api) {
console.warn("WavezFM room bridge did not become available");
}- A direct page load outside a room may not define
window.WavezFM. api.room.getState()returnsnulluntil room state is ready.- After leaving a room through client-side navigation, the bridge object may remain installed while
getState()returnsnulland actions returnunavailableormissing_room. - Do not retain a room-state snapshot indefinitely. Call
getState()again or subscribe to events when fresh data is required.
The bridge belongs to the page's JavaScript context. Browser-extension content scripts that run in an isolated world may not be able to read page globals directly. Run the integration in the page's main world, or inject a page script and communicate with the content script through DOM events or window.postMessage.
room.getState() returns the latest complete room snapshot, or null when no room controller is active.
const state = window.WavezFM?.room.getState();
if (state) {
console.log("Room:", state.room.name);
console.log("Track:", state.playback?.title ?? "Nothing playing");
console.log("Woots:", state.votes.woots);
console.log("Users:", state.users.length);
}Treat returned objects and arrays as read-only snapshots. Mutating them does not update WavezFM.
type WavezRoomState = {
room: {
id: string;
slug: string;
name: string;
description: string;
isVerified: boolean;
isPartner: boolean;
viewerRole: string;
queueLocked: boolean;
activeUsersCount: number;
queueCount: number;
};
currentUser: {
id: string;
username: string;
displayUsername: string;
globalRole: string;
roomRole: string | null;
level: number | null;
currentLevelXp: number | null;
xpRequired: number | null;
totalXp: number | null;
fanCount: number | null;
infiniteLevel: boolean;
} | null;
playback: {
playbackKey: string;
trackId: string;
source: "youtube" | "soundcloud";
sourceId: string;
title: string;
artist: string;
thumbnailUrl: string | null;
durationMs: number;
isLive: boolean;
startedAtServerMs: number;
djId: string;
djUsername: string;
} | null;
votes: {
trackId: string | null;
woots: number;
mehs: number;
grabs: number;
wootUserIds: string[];
mehUserIds: string[];
grabUserIds: string[];
clientVote: "woot" | "meh" | null;
clientGrabbed: boolean;
clientGrabPlaylistId: string | null;
canVote: boolean;
};
queue: {
userIds: string[];
count: number;
isJoined: boolean;
isCurrentDj: boolean;
isLocked: boolean;
isFull: boolean;
currentDjId: string | null;
currentDjUsername: string | null;
playbackTrackId: string | null;
entries: Array<{
userId: string;
username: string;
displayUsername: string | null;
rawUsername: string | null;
handle: string | null;
avatar: string | null;
role: string;
platformRole: string;
level: number | null;
xp: number | null;
fanCount: number | null;
infiniteLevel: boolean;
isSuperfan: boolean;
isFollowing: boolean;
position: number;
queuedTrackDurationMs: number | null;
estimatedWaitMs: number | null;
estimatedWaitKind: "ready" | "live" | "unknown";
}>;
};
users: Array<{
id: string;
username: string;
displayUsername: string | null;
rawUsername: string | null;
handle: string | null;
role: string;
platformRole: string;
avatar: string | null;
level: number | null;
xp: number | null;
fanCount: number | null;
infiniteLevel: boolean;
isSuperfan: boolean;
isFollowing: boolean;
}>;
social: {
superfanIds: string[];
followingIds: string[];
superfansCount: number;
followingCount: number;
isFollowingCurrentDj: boolean;
isSuperfanWithCurrentDj: boolean;
};
progress: {
currentUser: {
id: string;
username: string;
level: number | null;
currentLevelXp: number | null;
xpRequired: number | null;
totalXp: number | null;
fanCount: number | null;
infiniteLevel: boolean;
} | null;
users: Array<{
userId: string;
username: string;
level: number | null;
xp: number | null;
fanCount: number | null;
infiniteLevel: boolean;
}>;
};
volume: number;
permissions: {
vote: boolean;
joinQueue: boolean;
sendChat: boolean;
};
};username,rawUsername, andhandlecontain the account handle used for mentions and lookups.displayUsernamecontains the visible display name when one is available.currentUserisnullfor guest previews and whenever no authenticated room user is available.
Use playback.playbackKey to identify one playback session. It combines the track identity and server start time, so replaying the same track later produces a different key.
The playback snapshot does not expose a paused field. Room playback timing is server-based through startedAtServerMs and durationMs.
Subscribe to real-time bridge updates with room.subscribe():
const api = window.WavezFM;
const unsubscribe = api.room.subscribe(
"playback_changed",
(playback) => {
if (playback) {
console.log("Now playing:", playback.title);
} else {
console.log("Playback ended");
}
},
);
// Remove the listener when the integration is disabled.
unsubscribe();| Event | Callback detail | Description |
|---|---|---|
room_changed |
WavezRoomState["room"] | null |
Room metadata changed or the room was left |
playback_changed |
WavezRoomState["playback"] |
Playback changed, ended, or was cleared |
votes_changed |
WavezRoomState["votes"] |
Vote counts or the current user's vote state changed |
queue_changed |
WavezRoomState["queue"] |
Queue members, positions, ETA, or queue state changed |
users_changed |
WavezRoomState["users"] |
The visible room-user list changed |
chat_message |
WavezChatMessage |
A new room chat message was received |
social_changed |
WavezRoomState["social"] |
Following or superfan state changed |
progress_changed |
WavezRoomState["progress"] |
XP, level, or fan data changed |
type WavezChatMessage = {
id: string;
roomId: string;
userId: string;
username: string;
content: string;
timestamp: string;
role: string;
platformRole: string;
replyToId: string | null;
system: boolean;
};The subscription callback receives the event detail directly, not the browser Event object.
Native DOM listeners are also supported. Event names are available through api.events to avoid hardcoded strings.
const api = window.WavezFM;
function handlePlaybackChanged(event) {
console.log(event.detail);
}
window.addEventListener(
api.events.playbackChanged,
handlePlaybackChanged,
);
window.removeEventListener(
api.events.playbackChanged,
handlePlaybackChanged,
);Available constants:
| Property | DOM event name |
|---|---|
api.events.roomChanged |
WavezFM:room_changed |
api.events.playbackChanged |
WavezFM:playback_changed |
api.events.votesChanged |
WavezFM:votes_changed |
api.events.queueChanged |
WavezFM:queue_changed |
api.events.usersChanged |
WavezFM:users_changed |
api.events.chatMessage |
WavezFM:chat_message |
api.events.socialChanged |
WavezFM:social_changed |
api.events.progressChanged |
WavezFM:progress_changed |
Actions are synchronous. They return immediately with a local acceptance result; they do not return a Promise.
const result = window.WavezFM.actions.vote("woot");
if (!result.ok) {
console.warn("Vote was not dispatched:", result.code);
}An ok result means the room client accepted or dispatched the action. Server-side validation can still reject a WebSocket action afterward. Use live room state and normal WavezFM feedback as the final source of truth.
type WavezActionResultCode =
| "ok"
| "unavailable"
| "missing_room"
| "missing_playback"
| "self_vote_not_allowed"
| "invalid_content"
| "rejected"
| "queue_locked"
| "queue_full"
| "already_in_queue"
| "not_in_queue"
| "current_dj";
type WavezActionResult = {
ok: boolean;
code: WavezActionResultCode;
requestId?: string | null;
value?: number;
};requestIdmay be returned for WebSocket actions such as voting and queue changes.valueis returned bysetVolume()with the final normalized volume.
const state = window.WavezFM.room.getState();
if (state?.permissions.vote) {
const result = window.WavezFM.actions.vote("woot");
console.log(result);
}Supported values are "woot" and "meh". Grab is not exposed as a bridge action because it requires the playlist-selection flow in the WavezFM UI.
Possible action-specific failures:
missing_playbackself_vote_not_allowed
const result = window.WavezFM.actions.joinQueue();Possible action-specific failures:
current_djalready_in_queuequeue_lockedqueue_full
const result = window.WavezFM.actions.leaveQueue();Possible action-specific failure:
not_in_queue
The current DJ may also use this action to leave the booth.
const state = window.WavezFM.room.getState();
if (state?.permissions.sendChat) {
const result = window.WavezFM.actions.sendChat("Hello, room!");
console.log(result);
}Possible action-specific failures:
invalid_contentrejected
The action uses the current public room-chat context. Guest previews and users without chat permission cannot use it successfully.
const result = window.WavezFM.actions.setVolume(25);
if (result.ok) {
console.log("Volume set to", result.value);
}- Input is rounded and clamped to the
0through100range. - The result's
valuecontains the normalized volume.
- Any action can return
unavailablewhen the room controller or a required live connection is unavailable. - Room-dependent actions can return
missing_roomwhen no active room exists.setVolume()only depends on an active room controller and therefore returnsunavailablewhen that controller is absent.
Check the current permission flags before showing integration controls:
const { permissions } = window.WavezFM.room.getState() ?? {};
console.log({
canVote: permissions?.vote === true,
canJoinQueue: permissions?.joinQueue === true,
canSendChat: permissions?.sendChat === true,
});The following example votes once for each playback session:
(() => {
const api = window.WavezFM;
if (!api || api.version !== "1") {
console.warn("WavezFM room bridge is unavailable");
return;
}
let lastPlaybackKey = null;
function voteForPlayback(playback = api.room.getState()?.playback ?? null) {
if (!playback || playback.playbackKey === lastPlaybackKey) {
return;
}
lastPlaybackKey = playback.playbackKey;
const state = api.room.getState();
if (!state?.votes.canVote || state.votes.clientVote === "woot") {
return;
}
const result = api.actions.vote("woot");
if (!result.ok) {
console.warn("AutoWoot was not dispatched:", result.code);
}
}
voteForPlayback();
const unsubscribe = api.room.subscribe(
"playback_changed",
voteForPlayback,
);
// Call unsubscribe() when the integration is disabled.
})();- Prefer
window.WavezFMover DOM queries and simulated UI interaction. - Check
api.versionbefore using the bridge. - Check
room.getState()fornullduring startup and after room navigation. - Treat state and event payloads as read-only snapshots.
- Use
playback.playbackKeyinstead of title, artist, ortrackIdalone to detect a new playback session. - Check
state.permissionsbefore presenting vote, queue, or chat controls. - Handle every
ok: falseresult and branch on the stablecodevalue. - Do not treat a synchronous
okresult as confirmation that the server persisted the action. - Unsubscribe from bridge events when the extension or script is disabled.
- Do not automate abusive behavior, spam chat, or bypass room permissions and rate limits.
version: "1" is the compatibility key for this bridge. WavezFM may add optional fields, events, or actions without changing the meaning of existing v1 fields. Integrations should ignore unknown fields and avoid rejecting snapshots that contain additional data.