# VibeLive SDK -- API Reference & Example

**Build an anonymous video chat app with VibeLive**

Version 0.86 | June 18, 2026

## Implementation Directives

1. **Use ONLY the methods, properties, and events documented below.** Do not infer or invent any API not listed in the Methods, State Getters, or Events tables. If you need functionality not covered, leave a `// TODO` comment.
2. **Wire ALL Required events** from the Event Checklist. Do not skip any.
3. **Follow the example's code structure.** Do not combine separate handlers into unified abstractions (e.g., merging camera and screenshare logic into one function).
4. **Read actual state from SDK getters.** Never hardcode indicator values -- always use `getMemberState(id)` or read `mediaState` / `screenState`.
5. **Include all `<video>` attributes** shown in the example: `autoplay`, `playsinline`, `muted`.
6. **`createTile` must return a valid element** for both new and existing tiles.

## Setup

**Option A -- ESM** (recommended for `type="module"` scripts):
```html
<script type="module">
import VibeLive from 'https://makedo.com/sdk/makedo-vibelive.esm.min.js';
VibeLive.init({ projectId: 'YOUR_TRIAL_PROJECT_ID' });
</script>
```

**Option B -- IIFE** (plain `<script>` tag, no import):
```html
<script src="https://makedo.com/sdk/makedo-vibelive.min.js"></script>
<script>
VibeLive.init({ projectId: 'YOUR_TRIAL_PROJECT_ID' });
</script>
```

> Do NOT `import` from `.min.js` -- the IIFE bundle has no `export default`.

`VibeLive` is attached to `window` automatically -- usable in `onclick` handlers without extra wiring.

### Singleton vs. Multiple Instances

**Most apps need only the singleton.** `VibeLive.init()` creates a single shared instance attached to `window.VibeLive`. All examples in this guide use the singleton.

For the rare case where one page must host **multiple independent meetings** (e.g., a monitoring dashboard, side-by-side rooms), use `createInstance()`:

```js
const room1 = VibeLive.createInstance({ projectId: 'YOUR_TRIAL_PROJECT_ID' });
const room2 = VibeLive.createInstance({ projectId: 'YOUR_TRIAL_PROJECT_ID' });

await room1.signup('Alice');
await room1.enterByRoomCode('ABC123');

await room2.signup('Alice');
await room2.enterByRoomCode('DEF456');
```

Each instance has its own WebRTC connection, WebSocket, member tracking, and media state. The API surface is identical to the singleton -- every method, getter, and event listed below works the same way. Instances do not share state.

**Use the singleton (`VibeLive.init()`) unless you have a concrete need for multiple simultaneous rooms.**

## Lifecycle

```
[Not joined] -> signup/login -> enterByRoomCode(code) -> [PRE-LIVE] -> startLive() -> [LIVE] -> stopLive() -> [PRE-LIVE]
```

- **PRE-LIVE**: In room, camera/mic available for preview, no WebRTC connection.
- **LIVE**: WebRTC connected, streaming to/from other members.

## Rules (must follow)

1. **Use `registerTile()` for all tiles**: After creating a tile DOM element, call `VibeLive.registerTile(memberId, streamType, element)`. This auto-finds the `<video>` inside and registers it (replaces `setLocalCamera`/`setRemoteCamera` etc.). Registered tiles are **auto-removed from the DOM** when a member exits, a screenshare ends, you leave the room, or you get kicked. Do NOT manually remove registered tiles -- the SDK handles lifecycle cleanup. (Set `tileAutoRemove: false` in `init()` to keep the element in the DOM for a grace animation, then call `unregisterTile()` when done.)

2. **Visibility is yours**: The SDK attaches streams to `<video>` elements (`srcObject`) but never shows or hides anything. Use `memberStateChange` to toggle visibility and update indicators for all members.

3. **Video off != stream end**: A remote member can turn off video while keeping audio active. The stream stays alive (`remoteStreamEnd` does NOT fire), but the video track goes silent and the `<video>` element would otherwise **freeze on the last frame**. This is not a disconnect — `remoteLeft` will NOT fire. By default the SDK auto-hides the `<video>` element it owns inside a registered tile whenever that stream's video is not `'ON'` (it toggles a `.vibelive-video-off` class → `display:none`), so the frozen frame never shows — audio keeps flowing untouched. You still own the *tile chrome*: use `memberStateChange` (`video` becomes `'OFF'` or `'MUTED'`) to swap in a placeholder, dim the tile, or update indicators. To render the frozen frame yourself instead, opt out with `init({ autoHideStoppedVideo: false })`.

4. **Use detail fields for state**: Read `.videoDetail` / `.audioDetail` (returns `'ON'` | `'MUTED'` | `'OFF'`), NOT `.video` / `.audio` (booleans that stay `true` when muted).

5. **`await` everything async**: All methods that touch network (`signup`, `login`, `startLive`, `setVideo`, `enterByRoomCode`, etc.) return promises.

6. **Use `getMembers()` for full member lists**: `VibeLive.user` is the raw auth object (has `.username`). For `displayName`, read `state.displayName` directly from `memberStateChange` — it is included in the state object and is the simplest way to keep name labels current. Call `await VibeLive.getMembers()` when you need the full list with `displayStatus` (e.g. for green room / waiting room UIs). Keep the list current by updating on each `memberStateChange` event using `state.status`.

7. **One tile per stream type**: Each member can have a camera tile AND a screenshare tile. ID pattern: `tile-{memberId}-camera`, `tile-{memberId}-screenshare`.

8. **Stream exists != all tracks are ON**: When `remoteStreamStart` fires, do NOT assume video/audio are both active. Always read actual state from `VibeLive.getMediaStates(id)`. A member can start a stream with video on and audio off (or vice versa).

9. **Read state immediately after creating a tile**: After calling `createTile()` (or equivalent) in `remoteJoined` or `remoteStreamStart`, always call `VibeLive.getMemberState(memberId)` and pass the result to your tile-update function. When a guest joins from a fresh browser, authentication takes longer than usual — the SDK fires `memberStateChange` (with `video: 'ON'`) before the tile DOM element exists, the update is silently dropped, and nothing fires again to correct it. Reading state explicitly right after tile creation catches this missed event regardless of timing:

```js
VibeLive.on('remoteJoined', (id) => {
    const m = VibeLive.getMember(id);
    createTile(id, m?.displayName || 'Remote', 'camera', false);
    // Catch any memberStateChange that fired before this tile existed
    const state = VibeLive.getMemberState(id);
    if (state) updateTileFromState(id, state);
});
```

10. **Never auto-create tiles inside your state-update function**: It is tempting to add a fallback inside your `memberStateChange` handler — "if the tile doesn't exist yet, create it here." Don't. Tile creation must only happen in `remoteStreamStart` (or `remoteJoined`), because that is the only place `registerTile()` is called. If you create a tile anywhere else, the SDK has no `<video>` element registered for that member and the stream will never render — the tile appears but stays permanently blank. Rule 9's `getMemberState()` call immediately after `createTile()` already handles the timing race that makes the fallback seem necessary.

## Event Checklist

Every event below exists for a specific reason. **Omitting any REQUIRED event causes incorrect state, stale UI, or missing cleanup.**

### Required -- omitting these causes bugs

| Event | Why it's required |
|-------|-------------------|
| `channelSelected` | Set up local tiles, show controls, enable buttons |
| `memberStateChange` | **Unified state for any member** -- updates LIVE/VID/AUD indicators and placeholder visibility. Fires on every state change for every member (local and remote). Same shape always. |
| `remoteStreamStart` | Create remote tile via `registerTile()` -- without this, no remote video appears. **After creating the tile, call `getMemberState(id)` and apply it** (see rule 9: slow-auth race). |

### Recommended -- omitting these degrades experience

| Event | Why it's recommended |
|-------|----------------------|
| `localJoined` | Update button enabled/disabled state on going live |
| `localLeft` | Update button state on stop-live |
| `remoteJoined` | Pre-create tile before stream arrives (avoids flash of missing content). **After creating the tile, call `getMemberState(id)` and apply it** — a `memberStateChange` may have fired before the tile existed (slow-auth race on guest join). Also the correct signal for partner arrival in invite/pairing flows — fires reliably even when the invited member was pre-populated before they accepted. |
| `remoteStreamStart` | Create remote tile via `registerTile()`. **After creating the tile, call `getMemberState(id)` and apply it** for the same slow-auth race reason. |
| `kicked` | Handler receives a `MakedoError` (`.message` for display text; `.reason`/`.source` are reserved for structured kicks and are currently `null` for time-limit kicks). Show message to user, call `exitRoom()` to clean up, return to entry screen |
| `kickWarning` | Advance notice that a kick is coming — the counterpart to `livelinessCheck`. Fires for time-limited sessions shortly before the session-end kick so you can show a countdown. **Non-destructive**: nothing is torn down — do NOT call `exitRoom()` here (the terminal `kicked` handles teardown). Payload `{ scope, reason, source, secondsRemaining, deadline, message }` (today `scope:'room'`, `reason:'session_timeout'`, `source:'system'`). The `deadline` field (epoch ms) lets you drive a smooth client-side countdown with no further server polling — see *Session-end countdown patterns* in the State Properties section. Complements `time_remaining_seconds` (available on the channel object) which is better suited for showing a time badge when a user first joins. |
| `error` | Display or log `MakedoError` with `.code`, `.hint` for debugging |
| `warning` | Log non-fatal issues (e.g. brief network interruptions) |

### Optional -- SDK handles these automatically when using `registerTile()`

| Event | Notes |
|-------|-------|
| `remoteStreamEnd` | Screenshare tiles auto-removed by SDK. Only needed if you want custom animation before removal. |
| `remoteLeft` | Tiles auto-removed for exited members by SDK. Only needed if you want custom exit behavior. |
| `localMediaChange` | Covered by `memberStateChange` for indicators. Only needed for extra local-only UI. |
| `localLiveChange` | Covered by `memberStateChange`. Only needed if you want a separate local-only live handler. |
| `remoteLiveChange` | Covered by `memberStateChange`. Only needed if you want a separate remote-only live handler. |
| `remoteMediaChange` | Covered by `memberStateChange`. Only needed if you want a separate remote-only media handler. |
| `livelinessCheck` | Server detected silent media (tab hidden / mic+cam off for several minutes). Prompt the user and call `VibeLive.ackLiveliness()` if they respond. If ignored, the server auto-removes the member after ~5 minutes — safe, but silent. |

## Complete Working Example

```html
<!DOCTYPE html>
<html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
  body { background: #000; color: #fff; font-family: system-ui; margin: 0; padding: 1rem; }
  button, input { background: #000; color: #fff; border: 1px solid #666; padding: .4rem .8rem; margin: .2rem; }
  button:hover { background: #fff; color: #000; cursor: pointer; }
  button:disabled { opacity: .3; cursor: default; }
  .grid { display: flex; flex-wrap: wrap; gap: .5rem; margin-top: 1rem; }
  .tile { position: relative; width: 320px; height: 240px; background: #111; overflow: hidden; }
  .tile video { width: 100%; height: 100%; object-fit: cover; display: none; }
  .tile video.visible { display: block; }
  .tile .placeholder { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; }
  .tile .placeholder.hidden { display: none !important; }
  .tile .name { position: absolute; bottom: 4px; left: 4px; font-size: 11px; background: rgba(0,0,0,.6); padding: 2px 6px; }
  .ind { position: absolute; top: 4px; left: 4px; font-size: 10px; font-weight: bold; margin-right: 4px; }
  .ind.on { color: #0f0; } .ind.off { color: #f00; }
</style>
</head><body>

<div id="entry">
  <input id="nameInput" placeholder="Your name">
  <input id="codeInput" placeholder="Room code">
  <button onclick="join()">Join</button>
  <button onclick="createRoom()">Create Room</button>
</div>

<div id="controls" style="display:none">
  <span id="roomInfo"></span>
  <button id="btnLive" onclick="VibeLive.startLive()" disabled>Go Live</button>
  <button id="btnStop" onclick="VibeLive.stopLive()" disabled>Stop Live</button>
  <button onclick="VibeLive.setVideo(!VibeLive.mediaState.video)">Video</button>
  <button onclick="VibeLive.setAudio(!VibeLive.mediaState.audio)">Mic</button>
  <button onclick="VibeLive.setScreenshare(!VibeLive.screenState.video)">Screen</button>
  <button onclick="VibeLive.exitRoom()">Exit</button>
</div>

<div class="grid" id="grid"></div>

<script type="module">
import VibeLive from 'https://makedo.com/sdk/makedo-vibelive.esm.min.js';
VibeLive.init({ projectId: 'YOUR_TRIAL_PROJECT_ID' });

// ---- Helpers ----

function createTile(id, name, streamType, isLocal) {
    const tileId = `tile-${id}-${streamType}`;
    if (document.getElementById(tileId)) return document.getElementById(tileId);
    const tile = document.createElement('div');
    tile.className = 'tile';
    tile.id = tileId;
    const label = streamType === 'screenshare'
        ? `${isLocal ? 'Your' : name + "'s"} Screen`
        : name;
    tile.innerHTML = `
        <span class="ind ind-live off">LIVE</span>
        <span class="ind ind-vid off" style="left:40px">VID</span>
        <span class="ind ind-aud off" style="left:72px">AUD</span>
        <div class="placeholder"><span>${label}</span></div>
        <video autoplay playsinline ${isLocal || streamType === 'screenshare' ? 'muted' : ''}></video>
        <div class="name">${label}</div>`;
    document.getElementById('grid').appendChild(tile);
    if (streamType === 'screenshare') tile.style.display = 'none';
    // registerTile auto-finds <video> and registers it (replaces setLocalCamera/setRemoteCamera).
    // SDK auto-removes registered tiles on member exit, screenshare end, room exit, or kick.
    VibeLive.registerTile(id, streamType, tile);
    return tile;
}

function setIndicator(tileId, type, isOn) {
    const el = document.getElementById(tileId)?.querySelector(`.ind-${type}`);
    if (el) { el.classList.toggle('on', isOn); el.classList.toggle('off', !isOn); }
}

function updateTileVisibility(tileId, videoOn) {
    const tile = document.getElementById(tileId);
    if (!tile) return;
    tile.querySelector('.placeholder')?.classList.toggle('hidden', videoOn);
    tile.querySelector('video')?.classList.toggle('visible', videoOn);
}

function updateButtons() {
    const live = VibeLive.isLive;
    document.getElementById('btnLive').disabled = live;
    document.getElementById('btnStop').disabled = !live;
}

// ---- Events ----

VibeLive.on('channelSelected', async (channel) => {
    document.getElementById('entry').style.display = 'none';
    document.getElementById('controls').style.display = 'block';
    const members = await VibeLive.getMembers();
    const self = members.find(m => m.id === VibeLive.memberId);
    createTile(VibeLive.memberId, self?.displayName || 'You', 'camera', true);
    createTile(VibeLive.memberId, self?.displayName || 'You', 'screenshare', true);
    updateButtons();
});

VibeLive.on('localJoined', () => {
    updateButtons();
});

VibeLive.on('localLeft', () => {
    updateButtons();
});

// ---- Unified state handler -- indicators + visibility for ALL members ----

VibeLive.on('memberStateChange', (id, state) => {
    // state = { status: 'LIVE'|'PRE-LIVE'|'EXITED', live: boolean, video, audio, screenVideo }
    const camTile = `tile-${id}-camera`;

    // Camera indicators
    setIndicator(camTile, 'live', state.live);
    setIndicator(camTile, 'vid', state.video === 'ON');
    setIndicator(camTile, 'aud', state.audio === 'ON');

    // Camera placeholder <-> video visibility
    updateTileVisibility(camTile, state.video === 'ON');

    // Screenshare tile show/hide
    const screenTile = document.getElementById(`tile-${id}-screenshare`);
    if (screenTile) {
        screenTile.style.display = state.screenVideo !== 'OFF' ? 'block' : 'none';
        screenTile.querySelector('.placeholder')?.classList.toggle('hidden', state.screenVideo !== 'OFF');
        screenTile.querySelector('video')?.classList.toggle('visible', state.screenVideo !== 'OFF');
    }
});

VibeLive.on('remoteJoined', (id) => {
    const m = VibeLive.getMember(id);
    createTile(id, m?.displayName || 'Remote', 'camera', false);
});

VibeLive.on('remoteStreamStart', (id, type) => {
    const m = VibeLive.getMember(id);
    createTile(id, m?.displayName || 'Remote', type, false);
    // registerTile (inside createTile) handles video element registration.
    // memberStateChange handles indicators. Nothing else needed.
});

// remoteStreamEnd: screenshare tiles auto-removed by SDK (registered tile).
// remoteLeft: tiles auto-removed by SDK when member exits (registered tiles).

VibeLive.on('kicked', (message) => {
    alert(message || 'You have been removed from this room.');
    VibeLive.exitRoom();  // SDK auto-removes all registered tiles
    document.getElementById('controls').style.display = 'none';
    document.getElementById('entry').style.display = 'block';
});

// Advance warning before a time-limited session ends. Non-destructive --
// show a countdown; the terminal 'kicked' fires when the grace period expires.
VibeLive.on('kickWarning', (w) => {
    // w = { scope, reason, source, secondsRemaining, deadline, message }
    console.warn(`Session ending in ${w.secondsRemaining}s (${w.reason})`);
    // e.g. show a banner: `Your session ends in ${w.secondsRemaining} seconds`
});

VibeLive.on('error', (context, error) => {
    console.error(`[${context}]`, error.code || '', error.message);
});

VibeLive.on('warning', (context, message) => {
    console.warn(`[${context}]`, message);
});

// ---- Entry Actions ----

window.join = async function() {
    const name = document.getElementById('nameInput').value.trim() || 'Guest';
    const code = document.getElementById('codeInput').value.trim();
    if (!code) return alert('Enter a room code');
    await VibeLive.signup(name);
    await VibeLive.enterByRoomCode(code);
};

window.createRoom = async function() {
    const name = document.getElementById('nameInput').value.trim() || 'Guest';
    await VibeLive.signup(name);
    const room = await VibeLive.createRoom('My Room');
    document.getElementById('roomInfo').textContent = `Room code: ${room.room_code}`;
    await VibeLive.enterByRoomCode(room.room_code);
};
</script>
</body></html>
```

## API Reference

### Methods

| Method | Returns | Description |
|--------|---------|-------------|
| `init(config)` | `VibeLive` | Initialize. `config`: `{ projectId, proxy?, projectKey?, serverUrl?, tileAutoRemove?, autoHideStoppedVideo?, features? }`. Set `tileAutoRemove: false` to make all tiles retain their DOM elements on member exit (SDK stops the stream but skips `element.remove()`). Default `true`. Call `unregisterTile()` to complete cleanup after a grace animation. `autoHideStoppedVideo` (default `true`): the SDK hides the `<video>` it owns inside a registered tile when that stream's video is not `'ON'`, preventing the frozen-last-frame trap; set `false` to render the frozen frame yourself. `features`: `{ connectionQuality?, showMemberQuality?, showLocalQuality? }` — enable network quality monitoring (see API_ADVANCED). |
| `signup(name)` | `Promise` | Anonymous signup. Must `await`. |
| `login(email, password, projectId?)` | `Promise` | Login with credentials. |
| `logout()` | `Promise` | Logout and cleanup. |
| `createRoom(title, options?)` | `Promise<Object>` | Create a room. The returned room object includes `id`, `room_code`, `title`, `time_remaining_seconds`, `is_live`, and other channel fields. `options`: `{ allowsGuests?, statusOnMeetingEnd?, retainRoomTexts?, retainDMTexts? }` |

**`createRoom(title, options?)` options:**

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `allowsGuests` | `boolean` | `true` | Allow anonymous (non-registered) users to join. |
| `statusOnMeetingEnd` | `string` | _(project default)_ | Room status set automatically when the last participant exits. One of `'open'`, `'closed'`, `'pending'`, `'disabled'`, `'archived'`. Overrides the project-level setting for this room only. Omit to inherit the project default. |
| `retainRoomTexts` | `boolean` | `false` | Keep room (public) chat messages after the meeting ends. By default, room messages are soft-deleted when the last participant exits. Set `true` to preserve them. Cascades: room config → project config → `RetainRoomTexts` in `ALLProperties.txt` → coded default `false`. |
| `retainDMTexts` | `boolean` | `false` | Keep direct-message texts sent between meeting participants after the meeting ends. By default, member-to-member DMs are soft-deleted when the last participant exits. Set `true` to preserve them. Cascades: room config → project config → `RetainDMTexts` in `ALLProperties.txt` → coded default `false`. |

```js
// Room that locks itself after each meeting (requires host to reopen)
const room = await VibeLive.createRoom('Weekly Standup', { statusOnMeetingEnd: 'closed' });

// Room that stays open and rejoinable (explicit, same as default)
const room = await VibeLive.createRoom('Help Desk', { statusOnMeetingEnd: 'open' });
```


| `enterByRoomCode(code, displayName?)` | `Promise` | Enter room -> PRE-LIVE. |
| `exitRoom()` | `void` | Leave room. Releases media. |
| `selectChannel(channelId, displayName?)` | `Promise` | Enter by channel ID -> PRE-LIVE. |
| `loadChannels()` | `Promise` | Load channel list (login flow). |
| `loadUsers()` | `Promise` | Load user list (login flow). |
| `selectUser(userId)` | `Promise` | Quick-chat with user -> PRE-LIVE. |
| `getChannelInfo(roomCode)` | `Promise<Object>` | Look up channel info without joining. |
| `backToList()` | `void` | Return to channel list (cleanup). |
| `startLive()` | `Promise` | Connect WebRTC -> LIVE. |
| `stopLive(keepMedia?)` | `void` | Disconnect WebRTC -> PRE-LIVE. `keepMedia` default `true`. |
| `setVideo(on)` | `Promise` | Set camera on/off. Idempotent. |
| `setAudio(on)` | `Promise` | Set microphone on/off. Idempotent. |
| `setScreenshare(on)` | `Promise` | Set screenshare on/off. Idempotent. |
| `setCamera(deviceId)` | `Promise` | Switch to a specific camera by `deviceId`. Turns video on if off. |
| `setMicrophone(deviceId)` | `Promise` | Switch to a specific microphone by `deviceId`. Turns audio on if off. |
| `getDevices()` | `Promise<Object>` | Enumerate devices. Returns `{ cameras, microphones, speakers }` -- arrays of `MediaDeviceInfo`. |
| `setVideoMuted(muted)` | `Promise` | Hide/show video (track stays alive). Idempotent. |
| `setAudioMuted(muted)` | `Promise` | Mute/unmute audio (track stays alive). Idempotent. |
| `setScreenshareMuted(muted)` | `Promise` | Hide/show screenshare (track stays alive). Idempotent. |
| `registerTile(memberId, streamType, element)` | `void` | **Recommended.** Register a tile DOM element. Auto-finds `<video>` inside and registers it. SDK auto-removes tile from DOM on member exit, screenshare end, room exit, or kick (unless `tileAutoRemove: false` was set in `init()`). |
| `unregisterTile(memberId, streamType)` | `void` | Manually unregister + remove a tile from DOM. Always performs a full DOM removal regardless of `tileAutoRemove`. Use this to finish cleanup after a grace animation when `tileAutoRemove: false` is set. |
| `setLocalCamera(videoEl)` | `void` | Register local camera `<video>`. Not needed when using `registerTile()`. |
| `setLocalScreen(videoEl)` | `void` | Register local screenshare `<video>`. Not needed when using `registerTile()`. |
| `setRemoteCamera(memberId, videoEl)` | `void` | Register remote camera `<video>`. Not needed when using `registerTile()`. |
| `setRemoteScreen(memberId, videoEl)` | `void` | Register remote screenshare `<video>`. Not needed when using `registerTile()`. |
| `clearLocalCamera()` | `void` | Unregister local camera element. Not needed when using `registerTile()`. |
| `clearLocalScreen()` | `void` | Unregister local screenshare element. Not needed when using `registerTile()`. |
| `clearRemoteCamera(memberId)` | `void` | Unregister remote camera element. Not needed when using `registerTile()`. |
| `clearRemoteScreen(memberId)` | `void` | Unregister remote screenshare element. Not needed when using `registerTile()`. |
| `getMember(memberId)` | `Object \| null` | `{ displayName, displayStatus, hasCamera, hasScreenshare }` |
| `getMemberIds()` | `string[]` | All member IDs in current channel. |
| `getMembers()` | `Promise<Array>` | Fetch all members: `[{ id, displayName, baseStatus, displayStatus }]` |
| `getMediaStates(memberId)` | `Object` | See State Getters below. |
| `getMemberState(memberId)` | `Object` | **Unified state triple** -- works for local AND remote. See State Getters below. |
| `getStream(memberId, streamType)` | `MediaStream \| null` | Raw stream. `streamType`: `'camera'` or `'screenshare'`. |
| `on(event, callback)` | `VibeLive` | Register event handler. Chainable. |
| `ackLiveliness()` | `void` | Acknowledge a server liveliness check. Call from within your `livelinessCheck` handler once the user confirms they are still present. Has no effect if no check is pending. |
| `createInstance(config)` | `Object` | Create an independent VibeLive instance for multi-room. Same API as the singleton. See "Singleton vs. Multiple Instances" above. |

### State Getters

| Getter | Returns | Notes |
|--------|---------|-------|
| `VibeLive.version` | `string` | SDK version string (e.g. `'0.83'`). Available on both the singleton and any `createInstance()` instance. |
| `VibeLive.isLoggedIn` | `boolean` | Authenticated? |
| `VibeLive.isLive` | `boolean` | WebRTC connected? |
| `VibeLive.user` | `Object \| null` | Raw auth user (`username`, NOT `displayName`). |
| `VibeLive.memberId` | `string \| null` | Your member ID in current room. |
| `VibeLive.roomCode` | `string \| null` | Current room's shareable code. |
| `VibeLive.channel` | `Object \| null` | Current channel object (snapshot from when the room was entered — see below). |
| `VibeLive.channels` | `Array` | Loaded channels (login flow). |

**`VibeLive.channel` key fields** — set once at room entry; `time_remaining_seconds` is a snapshot and does not count down:

| Field | Type | Description |
|-------|------|-------------|
| `id` | `string` | Channel ID. |
| `room_code` | `string` | Shareable join code. |
| `title` | `string` | Room title. |
| `is_live` | `boolean` | Whether a live session is currently active. |
| `time_remaining_seconds` | `number` | Seconds until the session is force-ended. `-1` = unlimited tier (no cap). `0` = no active meeting. `>0` = seconds remaining. |
| `status` | `string` | Room status (`open`, `active`, `closed`, …). |

**Session-end countdown patterns** — two complementary approaches; use both together for the best experience:

**Option A — `kickWarning` event (recommended for the in-session countdown).** The server pushes a single event shortly before the kick. The `deadline` field (epoch ms) lets you run a smooth, poll-free client-side ticker:
```js
VibeLive.on('kickWarning', (w) => {
    if (!w.deadline) return;
    const tick = setInterval(() => {
        const secsLeft = Math.max(0, Math.round((w.deadline - Date.now()) / 1000));
        showCountdown(secsLeft);  // e.g. "Room ends in 0:28"
        if (secsLeft === 0) clearInterval(tick);
    }, 1000);
});
// The terminal 'kicked' event fires when the grace period expires — call exitRoom() there.
```
The warning fires at a configurable lead time (default 30 s). `w.secondsRemaining` gives the same value without needing to compute from `deadline`.

**Option B — `time_remaining_seconds` polling (best for showing time on entry).** The value on `VibeLive.channel` is a snapshot from when the room was entered and goes stale. Use `getChannelInfo()` polling to show a live badge *before* any `kickWarning` fires (e.g. a user joins with 3 minutes left):
```js
let timer;
VibeLive.on('channelSelected', async () => {
    const room = await VibeLive.getChannelInfo(); // current channel
    if ((room?.time_remaining_seconds ?? -1) <= 0) return; // unlimited or no meeting
    timer = setInterval(async () => {
        const r = await VibeLive.getChannelInfo();
        const secs = r?.time_remaining_seconds ?? -1;
        if (secs > 0) showCountdown(secs);    // e.g. "Room ends in 4:32"
        else clearInterval(timer);            // meeting ended or unlimited
    }, 10_000);
});
VibeLive.on('memberStateChange', (id, state) => {
    if (state.status === 'EXITED' && id === VibeLive.memberId) clearInterval(timer);
});
```
**When to use which:** Use Option A when you want a precise, smooth per-second countdown in the final moments before the kick. Use Option B to display approximate time remaining from the moment the user enters the room. In a full implementation you would use both: Option B shows the initial badge on entry; Option A takes over with a precise ticker when `kickWarning` fires.
| `VibeLive.hasMedia` | `boolean` | Any local media active? |
| `VibeLive.mediaState` | see below | Local camera/mic state. |
| `VibeLive.screenState` | see below | Local screenshare state. |

**`VibeLive.mediaState`** -- local camera/mic:
```json
{ "audio": true, "video": true, "audioMuted": false, "videoMuted": false,
  "audioDetail": "ON", "videoDetail": "ON" }
```

**`VibeLive.screenState`** -- local screenshare:
```json
{ "audio": false, "video": true, "videoMuted": false, "videoDetail": "ON" }
```

**`VibeLive.getMemberState(memberId)`** -- unified state for any member (local or remote, same shape):
```json
{ "displayName": "Alice", "status": "LIVE", "live": true, "video": "ON", "audio": "MUTED", "screenVideo": "OFF" }
```
`displayName` is the member's display name (available immediately — no separate `getMembers()` call needed). `status` is `'LIVE'`, `'PRE-LIVE'`, or `'EXITED'`. `live` is a convenience boolean (`status === 'LIVE'`).

**`VibeLive.getMediaStates(memberId)`** -- remote member (same `detail` field names):
```json
{ "audioDetail": "ON", "videoDetail": "MUTED", "screenAudioDetail": "OFF", "screenVideoDetail": "ON" }
```

**Detail values**: `'ON'` (active), `'MUTED'` (track alive but muted/hidden), `'OFF'` (no track).

**IMPORTANT**: Use `.videoDetail` / `.audioDetail` for UI state, not `.video` / `.audio`. The booleans mean "track exists" and stay `true` when muted.

### Events

Register with `VibeLive.on(event, callback)`.

| Event | Callback signature | When it fires |
|-------|-------------------|---------------|
| `login` | `(user)` | Signup or login succeeded. |
| `loginError` | `(error)` | Signup or login failed. |
| `logout` | `()` | Logged out. |
| `channelsLoaded` | `(channels)` | `loadChannels()` completed. |
| `usersLoaded` | `(users)` | `loadUsers()` completed. |
| `channelSelected` | `(channel)` | Entered a room (PRE-LIVE). Create your local tiles here. |
| `localJoined` | `()` | You went LIVE (`startLive()` completed). |
| `localLeft` | `()` | You returned to PRE-LIVE (`stopLive()` or disconnected). |
| `memberStateChange` | `(memberId, state)` | **Any member's state changed.** `state`: `{ status, live, video, audio, screenVideo }` where `status` is `'LIVE'\|'PRE-LIVE'\|'EXITED'`, `live` is a boolean, and video/audio/screenVideo are `'ON'\|'MUTED'\|'OFF'`. Fires for local AND remote. Use this for all indicators + visibility. |
| `localLiveChange` | `(isLive: boolean)` | Your live status changed. Use for LIVE indicator. |
| `localMediaChange` | `()` | Your camera/mic/screen state changed. Read `VibeLive.mediaState` / `VibeLive.screenState`. |
| `remoteJoined` | `(memberId)` | Remote member connected to WebRTC (fires once per session). |
| `remoteLeft` | `(memberId)` | Remote member stopped streaming. Check `getMember(id).displayStatus` -- may be `'EXITED'` or `'PRE-LIVE'`. |
| `remoteStreamStart` | `(memberId, streamType)` | Remote stream arrived. `streamType`: `'camera'` or `'screenshare'`. Create tile + call `registerTile()` here. |
| `remoteStreamEnd` | `(memberId, streamType)` | Remote stream ended. Screenshare tiles auto-removed if registered. Only needed for custom cleanup UI. |
| `remoteLiveChange` | `(memberId, isLive)` | Remote member's live status changed (camera stream started/ended). |
| `remoteMediaChange` | `(memberId, streamType)` | Remote member's video/audio state changed (mute, unmute, video off/on). **Also needed for placeholder toggling** -- not just indicators. |
| `memberUpdate` | `(memberId)` | Member info/status updated in database. |
| `kicked` | `(message)` | You were removed from the room by the server. |
| `livelinessCheck` | `()` | Server detected silent media (all audio/video inactive for several minutes). Show a "Still there?" prompt and call `VibeLive.ackLiveliness()` if the user responds. If ignored, the server auto-removes the member after ~5 more minutes. |
| `error` | `(context, error)` | Async error. `error` is `MakedoError` with `.code`, `.hint`, `.retriable`. |
| `warning` | `(context, message)` | Non-fatal warning (e.g. element registered after stream started). |

### Error Codes

Access via `VibeLive.ErrorCodes`. Errors are `MakedoError` instances with `.code`, `.message`, `.hint`, `.retriable`, `.context`.

| Code | Category | Retriable |
|------|----------|-----------|
| `INIT_REQUIRED` | Init | No |
| `INIT_INVALID_CONFIG` | Init | No |
| `AUTH_FAILED` | Auth | No |
| `AUTH_SESSION_EXPIRED` | Auth | Yes |
| `AUTH_TOKEN_REFRESH_FAILED` | Auth | Yes |
| `AUTH_PROXY_FAILED` | Auth | No |
| `AUTH_FORBIDDEN` | Auth | No |
| `PROJECT_UNAVAILABLE` | Auth | No |
| `MEDIA_PERMISSION_DENIED` | Media | No |
| `MEDIA_DEVICE_BUSY` | Media | Yes |
| `MEDIA_NOT_FOUND` | Media | No |
| `MEDIA_OVERCONSTRAINED` | Media | Yes |
| `MEDIA_TRACK_REPLACE_FAILED` | Media | Yes |
| `MEDIA_TRACK_ENDED` | Media | Yes |
| `MEDIA_UNKNOWN` | Media | No |
| `SCREENSHARE_PERMISSION_DENIED` | Screenshare | No |
| `SCREENSHARE_UNSUPPORTED` | Screenshare | No |
| `SCREENSHARE_CANCELLED` | Screenshare | No |
| `SCREENSHARE_UNKNOWN` | Screenshare | No |
| `ROOM_NOT_FOUND` | Room | No |
| `ROOM_CLOSED` | Room | No |
| `ROOM_JOIN_FAILED` | Room | Yes |
| `ROOM_FULL` | Room | No |
| `ROOM_DISABLED` | Room | No |
| `ROOM_KICKED` | Room | No |
| `ROOM_SFU_UNREACHABLE` | Room | Yes |
| `ACCOUNT_LIMIT_REACHED` | Room | No |
| `NETWORK_HTTP_ERROR` | Network | Yes |
| `NETWORK_TIMEOUT` | Network | Yes |
| `NETWORK_OFFLINE` | Network | Yes |
| `NETWORK_WS_AUTH_FAILED` | Network | No |
| `NETWORK_WS_DISCONNECTED` | Network | Yes |
| `STATE_NOT_LOGGED_IN` | State | No |
| `STATE_NOT_IN_ROOM` | State | No |
| `STATE_ALREADY_LIVE` | State | No |
| `STATE_NO_ELEMENT` | State | No |
| `INVALID_PARAM` | Param | No |

> **Auth errors and `error.subcode`:** `AUTH_FORBIDDEN` and `PROJECT_UNAVAILABLE` carry a normalized `subcode` field set by the SDK (e.g., `"project_key_rejected"`, `"origin_not_whitelisted"`, `"project_expired"`, `"project_archived"`, `"project_disabled"`). Use `error.subcode` to act on the specific reason. `error.hint` contains the human-readable fix message. See ERROR_GUIDE for the full table.

## Pitfalls

1. **`VibeLive.user` has no `displayName`** -- it's the raw auth object. Use `getMembers()` for display names.
2. **`.audio` stays `true` when muted** -- use `.audioDetail === 'ON'` instead of `.audio`.
3. **Creating `<video>` elements in `localMediaChange`** -- too late. The stream is already produced. Create tiles in `channelSelected`.
4. **`remoteStreamStart` signature is `(memberId, streamType)`** -- two arguments, no stream object. Create tile + call `registerTile()` -- the SDK attaches streams automatically.
5. **`.hidden` CSS specificity** -- if your `.hidden` class uses `display: none` and a component class sets `display: flex`, the component wins. Use `!important` on `.hidden` or higher specificity.
6. **`setVideo(on)` vs `setVideoMuted(muted)`** -- `setVideo(false)` turns hardware off (`.videoDetail`: `'OFF'`). `setVideoMuted(true)` keeps capturing but hides (`.videoDetail`: `'MUTED'`). Same pattern for audio: `setAudio(false)` vs `setAudioMuted(true)`.
7. **Screenshare tiles must start hidden** -- set `display: none` initially. `memberStateChange` shows them when `screenVideo !== 'OFF'`.
8. **Do NOT manually remove registered tiles** -- calling `.remove()` on a tile you passed to `registerTile()` bypasses SDK cleanup. Use `unregisterTile()` if you need manual removal, or let the SDK handle it automatically.
9. **`setVideo(true)` uses the default camera** -- to let users pick a specific camera or microphone, call `getDevices()` to list available hardware, then `setCamera(deviceId)` or `setMicrophone(deviceId)`. On phones, the default is typically the front camera; `setCamera()` is the only way to reach the rear camera.
10. **`ROOM_FULL` and `ROOM_DISABLED` are not retriable** -- don't auto-retry. Show the user a clear message and let them navigate elsewhere.
11. **`ROOM_KICKED` fires as `kicked`, not `error`** -- when the server removes a member, the SDK emits the `kicked` event and does NOT emit a separate `error` event with code `ROOM_KICKED`. Handle kicks exclusively in your `kicked` handler. If you also handle `ROOM_KICKED` inside your `error` handler, you will get two teardown calls and two modals.

## React Integration

React requires different patterns from the vanilla example above. The API is identical -- only the wiring differs.

### Key Differences from Vanilla

- **Init once** in a top-level module or context provider, not inside a component.
- **`useRef`** for `<video>` elements -- React controls the DOM, so no `document.getElementById`.
- **`useEffect` with cleanup** for event wiring -- return a teardown function that calls `exitRoom()`.
- **Do NOT use `registerTile()`** -- it calls `element.remove()` on cleanup, which fights React's reconciler. Use `setLocalCamera`/`setRemoteCamera` directly with refs instead.
- **Read state via SDK getters** in event callbacks, then push into React state with `setState`.

### Complete React Example

```jsx
import { useState, useEffect, useRef, useCallback } from 'react';
import VibeLive from 'https://makedo.com/sdk/makedo-vibelive.esm.min.js';

VibeLive.init({ projectId: 'YOUR_TRIAL_PROJECT_ID' });

function VideoTile({ memberId, streamType, name, isLocal }) {
    const videoRef = useRef(null);

    useEffect(() => {
        const el = videoRef.current;
        if (!el) return;
        if (isLocal) {
            if (streamType === 'camera') VibeLive.setLocalCamera(el);
            else VibeLive.setLocalScreen(el);
        } else {
            if (streamType === 'camera') VibeLive.setRemoteCamera(memberId, el);
            else VibeLive.setRemoteScreen(memberId, el);
        }
        return () => {
            if (isLocal) {
                if (streamType === 'camera') VibeLive.clearLocalCamera();
                else VibeLive.clearLocalScreen();
            } else {
                if (streamType === 'camera') VibeLive.clearRemoteCamera(memberId);
                else VibeLive.clearRemoteScreen(memberId);
            }
        };
    }, [memberId, streamType, isLocal]);

    return (
        <div style={{ position: 'relative', width: 320, height: 240, background: '#111' }}>
            <video ref={videoRef} autoPlay playsInline muted={isLocal || streamType === 'screenshare'}
                style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
            <span style={{ position: 'absolute', bottom: 4, left: 4, fontSize: 11, background: '#000a', padding: '2px 6px', color: '#fff' }}>
                {streamType === 'screenshare' ? `${name} (Screen)` : name}
            </span>
        </div>
    );
}

export default function MeetingRoom() {
    const [inRoom, setInRoom] = useState(false);
    const [isLive, setIsLive] = useState(false);
    const [tiles, setTiles] = useState([]);       // [{ id, name, type, isLocal }]
    const [members, setMembers] = useState([]);    // [{ id, name, status }]
    const nameRef = useRef('');
    const codeRef = useRef('');

    // Helper: update one member in the members list
    const updateMember = useCallback((id, status) => {
        const m = VibeLive.getMember(id);
        const name = m?.displayName || 'Unknown';
        setMembers(prev => {
            if (status === 'EXITED') return prev.filter(x => x.id !== id);
            const idx = prev.findIndex(x => x.id === id);
            const entry = { id, name, status };
            if (idx >= 0) { const next = [...prev]; next[idx] = entry; return next; }
            return [...prev, entry];
        });
    }, []);

    // Helper: add a tile if not already present
    const addTile = useCallback((id, name, type, isLocal) => {
        setTiles(prev => {
            if (prev.some(t => t.id === id && t.type === type)) return prev;
            return [...prev, { id, name, type, isLocal }];
        });
    }, []);

    // Helper: remove tiles for a member
    const removeTiles = useCallback((id) => {
        setTiles(prev => prev.filter(t => t.id !== id));
    }, []);

    useEffect(() => {
        VibeLive.on('channelSelected', async () => {
            setInRoom(true);
            const list = await VibeLive.getMembers();
            const self = list.find(m => m.id === VibeLive.memberId);
            addTile(VibeLive.memberId, self?.displayName || 'You', 'camera', true);
            setMembers(list.map(m => ({ id: m.id, name: m.displayName, status: m.displayStatus })));
        });
        VibeLive.on('localJoined', () => setIsLive(true));
        VibeLive.on('localLeft', () => setIsLive(false));
        VibeLive.on('memberStateChange', (id, state) => updateMember(id, state.status));
        VibeLive.on('remoteStreamStart', (id, type) => {
            const m = VibeLive.getMember(id);
            addTile(id, m?.displayName || 'Remote', type, false);
        });
        VibeLive.on('remoteLeft', (id) => removeTiles(id));
        VibeLive.on('error', (ctx, err) => console.error(`[${ctx}]`, err.message));
        return () => { VibeLive.exitRoom(); };
    }, [addTile, removeTiles, updateMember]);

    const join = async () => {
        await VibeLive.signup(nameRef.current || 'Guest');
        await VibeLive.enterByRoomCode(codeRef.current);
    };

    if (!inRoom) {
        return (
            <div>
                <input placeholder="Name" onChange={e => nameRef.current = e.target.value} />
                <input placeholder="Room code" onChange={e => codeRef.current = e.target.value} />
                <button onClick={join}>Join</button>
            </div>
        );
    }

    return (
        <div>
            <div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
                {members.filter(m => m.status !== 'EXITED').map(m => (
                    <span key={m.id} style={{ padding: '4px 8px', border: '1px solid',
                        borderColor: m.status === 'LIVE' ? '#0f0' : '#ff0',
                        color: m.status === 'LIVE' ? '#0f0' : '#ff0', fontSize: 11 }}>
                        {m.name}
                    </span>
                ))}
            </div>
            <div>
                <button onClick={() => VibeLive.startLive()} disabled={isLive}>Go Live</button>
                <button onClick={() => VibeLive.stopLive()} disabled={!isLive}>Stop</button>
                <button onClick={() => VibeLive.setVideo(!VibeLive.mediaState.video)}>Cam</button>
                <button onClick={() => VibeLive.setAudio(!VibeLive.mediaState.audio)}>Mic</button>
            </div>
            <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}>
                {tiles.map(t => (
                    <VideoTile key={`${t.id}-${t.type}`} memberId={t.id}
                        streamType={t.type} name={t.name} isLocal={t.isLocal} />
                ))}
            </div>
        </div>
    );
}
```

### React Pitfalls

1. **Do NOT use `registerTile()`** in React -- the SDK's DOM removal conflicts with React's virtual DOM. Use `setLocalCamera`/`setRemoteCamera` with refs and manage tile visibility via React state.
2. **Do NOT call `VibeLive.init()` inside a component** -- it runs on every render. Call it once at module scope or in a context provider.
3. **Always return a cleanup function** from `useEffect` that calls `exitRoom()` -- otherwise hot-reload or unmount leaks the WebRTC connection.
4. **Use `useRef` for input values** in join forms -- `useState` causes re-renders on every keystroke which can interfere with SDK callbacks firing mid-render.

### Multi-Room in React

Use `createInstance()` per component. Each instance is independent:

```jsx
function MeetingPanel({ roomCode }) {
    const vl = useRef(null);
    if (!vl.current) vl.current = VibeLive.createInstance({ projectId: 'YOUR_TRIAL_PROJECT_ID' });

    useEffect(() => {
        const inst = vl.current;
        inst.on('channelSelected', () => { /* ... */ });
        inst.on('memberStateChange', (id, state) => { /* ... */ });
        inst.signup('Observer').then(() => inst.enterByRoomCode(roomCode));
        return () => { inst.exitRoom(); };
    }, [roomCode]);

    // ... render using vl.current instead of VibeLive
}

// Usage: three independent meeting panels
<MeetingPanel roomCode="ABC123" />
<MeetingPanel roomCode="DEF456" />
<MeetingPanel roomCode="GHI789" />
```
