Skip to content

Latest commit

 

History

History
1255 lines (1018 loc) · 39.7 KB

File metadata and controls

1255 lines (1018 loc) · 39.7 KB

Integration Patterns

Iframe Sizing (Important)

The Digital Samba SDK injects an iframe into the container element. The iframe does not auto-size - you must explicitly set dimensions on the container or the iframe will render very small.

CSS Solution (Recommended)

/* Container must have explicit dimensions */
.video-container {
  width: 100%;
  height: 100vh; /* or calc(100vh - headerHeight) */
  position: relative;
}

/* Target the injected iframe */
.video-container iframe {
  width: 100%;
  height: 100%;
  border: none;
}

React/Tailwind Example

{/* Parent needs explicit height */}
<div className="relative" style={{ height: 'calc(100vh - 60px)' }}>
  {/* Container targets child iframe with Tailwind arbitrary selectors */}
  <div
    ref={containerRef}
    className="absolute inset-0 [&>iframe]:w-full [&>iframe]:h-full [&>iframe]:border-0"
  />
</div>

Common Mistakes

Issue Cause Fix
Tiny iframe Container has no height Set explicit height on container
Iframe overflows No overflow: hidden Add overflow control to parent
Scrollbars appear iframe border Add border: none to iframe

Embedding Into Your Application

Three approaches to embed a Digital Samba video room into your UI, from simplest to most control. All assume you have a roomUrl (e.g., https://yourteam.digitalsamba.com/room-slug?token=xxx) from your server.

Approach 1: Plain HTML iframe (No SDK)

Simplest embedding — no JavaScript required:

<!DOCTYPE html>
<html>
<head>
  <style>
    .video-wrapper {
      width: 100%;
      height: calc(100vh - 60px); /* Full height minus header */
    }
    .video-wrapper iframe {
      width: 100%;
      height: 100%;
      border: none;
    }
  </style>
</head>
<body>
  <header style="height: 60px; padding: 16px;">My App</header>
  <div class="video-wrapper">
    <iframe
      allow="camera; microphone; display-capture; autoplay"
      src="https://yourteam.digitalsamba.com/my-room?token=YOUR_TOKEN"
      allowfullscreen="true">
    </iframe>
  </div>
</body>
</html>

Approach 2: SDK-Managed iframe (Recommended)

The SDK creates an iframe inside your container and gives you full control via events and methods:

import DigitalSambaEmbedded from '@digitalsamba/embedded-sdk';

// 1. SDK injects an iframe into this container element
const sambaFrame = DigitalSambaEmbedded.createControl({
  url: 'https://yourteam.digitalsamba.com/my-room?token=YOUR_TOKEN',
  root: document.getElementById('video-container')
});

// 2. Set up event listeners BEFORE loading
sambaFrame.on('frameLoaded', () => {
  console.log('iframe loaded, waiting for user to join...');
});

// e.data is { user, type } — type is 'local' for you, 'remote' for everyone else
sambaFrame.on('userJoined', (e) => {
  console.log(`${e.data.user.name} joined as ${e.data.user.role} (${e.data.type})`);

  if (e.data.type !== 'local') return;
  // Now safe to call control methods
  document.getElementById('mute-btn').onclick = () => sambaFrame.toggleAudio();
  document.getElementById('camera-btn').onclick = () => sambaFrame.toggleVideo();
  document.getElementById('leave-btn').onclick = () => sambaFrame.leaveSession();
});

sambaFrame.on('userLeft', (e) => {
  console.log(`${e.data.user.name} left`);
});

sambaFrame.on('appError', (e) => {
  console.error(`App error [${e.data.code}]: ${e.data.message}`);
});

sambaFrame.on('mediaConnectionFailed', () => {
  console.error('Media connection failed — check network/firewall');
});

// 3. Load the iframe (triggers 'frameLoaded' → user sees join screen → 'userJoined')
sambaFrame.load();

Approach 3: Wrap an Existing iframe with SDK

If you already have an iframe in your HTML and want to add SDK control:

<iframe
  id="existing-video"
  allow="camera; microphone; display-capture; autoplay"
  src="https://yourteam.digitalsamba.com/my-room?token=YOUR_TOKEN"
  style="width: 100%; height: 600px; border: none;"
  allowfullscreen="true">
</iframe>

<script type="module">
import DigitalSambaEmbedded from '@digitalsamba/embedded-sdk';

// Wrap the existing iframe to add SDK control
const sambaFrame = DigitalSambaEmbedded.createControl({
  frame: document.getElementById('existing-video')
});

// Now you can listen to events and call methods
sambaFrame.on('userJoined', (e) => {
  console.log(`${e.data.user.name} joined as ${e.data.user.role}`);
});

sambaFrame.on('recordingStarted', () => {
  console.log('Recording in progress');
});
</script>

Self-Contained React Component

A complete, single-file React component for embedding Digital Samba. No external hook dependencies:

import { useEffect, useRef, useState } from 'react';
import DigitalSambaEmbedded from '@digitalsamba/embedded-sdk';

interface EmbeddedRoomProps {
  roomUrl: string;  // Full URL with token, e.g. "https://team.digitalsamba.com/room?token=xxx"
  height?: string;  // Container height (default: "100vh")
  onJoined?: (user: { id: string; name: string; role: string }) => void;
  onLeft?: () => void;
  onError?: (message: string) => void;
}

export function EmbeddedRoom({ roomUrl, height = '100vh', onJoined, onLeft, onError }: EmbeddedRoomProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const sambaRef = useRef<DigitalSambaEmbedded | null>(null);
  const [status, setStatus] = useState<'loading' | 'ready' | 'joined' | 'error'>('loading');
  const [participants, setParticipants] = useState<Array<{ id: string; name: string }>>([]);

  useEffect(() => {
    if (!containerRef.current || !roomUrl) return;

    // Create SDK instance — this injects an iframe into the container
    const sambaFrame = DigitalSambaEmbedded.createControl({
      url: roomUrl,
      root: containerRef.current
    });
    sambaRef.current = sambaFrame;

    // Connection lifecycle
    sambaFrame.on('frameLoaded', () => setStatus('ready'));

    // e.data is { user, type } — only the 'local' join means *we* are in the room
    sambaFrame.on('userJoined', (e) => {
      if (e.data.type === 'local') setStatus('joined');
      onJoined?.(e.data.user);
    });

    sambaFrame.on('userLeft', (e) => {
      if (e.data.user.id === sambaFrame.localUser?.id) onLeft?.();
    });

    // e.data is { users } — not a bare array
    sambaFrame.on('usersUpdated', (e) => {
      setParticipants(e.data.users.map((u: any) => ({ id: u.id, name: u.name })));
    });

    // Error handling
    sambaFrame.on('mediaConnectionFailed', () => {
      setStatus('error');
      onError?.('Media connection failed — check network/firewall');
    });

    sambaFrame.on('mediaPermissionsFailed', () => {
      onError?.('Camera/microphone access was denied');
    });

    sambaFrame.on('appError', (e) => {
      onError?.(e.data?.message || 'Application error');
    });

    // Load the iframe. reportErrors makes setup problems (bad URL, insecure
    // context) throw instead of only logging to the console.
    sambaFrame.load({ reportErrors: true });

    // Cleanup on unmount: leave the session, then remove the injected iframe.
    // The SDK has no destroy() — without removing the frame, a remount stacks iframes.
    return () => {
      sambaFrame.leaveSession();
      sambaFrame.frame?.remove();
      sambaRef.current = null;
    };
  }, [roomUrl]);

  return (
    <div style={{ width: '100%', height, display: 'flex', flexDirection: 'column' }}>
      {/* SDK injects iframe here — container MUST have explicit dimensions */}
      <div
        ref={containerRef}
        style={{ flex: 1, position: 'relative', backgroundColor: '#1a1a1a', borderRadius: 8, overflow: 'hidden' }}
      />

      {/* Status bar */}
      <div style={{ padding: '8px 12px', fontSize: 14, backgroundColor: '#f5f5f5' }}>
        {status === 'loading' && 'Connecting...'}
        {status === 'ready' && 'Ready to join'}
        {status === 'joined' && `In call — ${participants.length} participant${participants.length !== 1 ? 's' : ''}`}
        {status === 'error' && 'Connection failed'}
      </div>

      {/* Controls — only available after joining */}
      {status === 'joined' && (
        <div style={{ display: 'flex', gap: 8, padding: 8 }}>
          <button onClick={() => sambaRef.current?.toggleAudio()}>Toggle Mic</button>
          <button onClick={() => sambaRef.current?.toggleVideo()}>Toggle Camera</button>
          <button onClick={() => sambaRef.current?.startScreenshare()}>Share Screen</button>
          <button onClick={() => sambaRef.current?.leaveSession()}>Leave</button>
        </div>
      )}
    </div>
  );
}

// Usage:
// <EmbeddedRoom
//   roomUrl="https://yourteam.digitalsamba.com/my-room?token=xxx"
//   height="calc(100vh - 60px)"
//   onJoined={(user) => console.log(`Joined as ${user.name}`)}
//   onError={(msg) => alert(msg)}
// />

Pattern 1: Simple Public Room

Best for: Quick demos, open meetings

// Server: Create public room
const room = await fetch('https://api.digitalsamba.com/api/v1/rooms', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${DEVELOPER_KEY}`,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    friendly_url: 'open-meeting',
    privacy: 'public',
    join_screen_enabled: true
  })
}).then(r => r.json());

// Client: Embed directly
const iframe = document.createElement('iframe');
iframe.src = `https://${TEAM_DOMAIN}.digitalsamba.com/${room.friendly_url}`;
iframe.allow = 'camera; microphone; display-capture; autoplay';

Pattern 2: Authenticated Users

Best for: SaaS integrations, known users

// Server: Create room + generate token with error handling
const jwt = require('jsonwebtoken');

app.post('/api/room-token', async (req, res) => {
  try {
    // Create or fetch room
    const roomRes = await fetch('https://api.digitalsamba.com/api/v1/rooms', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${DEVELOPER_KEY}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        friendly_url: req.body.roomSlug,
        privacy: 'private',
        default_role: 'attendee',
        roles: ['moderator', 'speaker', 'attendee']
      })
    });

    if (!roomRes.ok) {
      const err = await roomRes.json();
      // 422 = validation error (e.g., friendly_url taken, roles missing)
      return res.status(roomRes.status).json({ error: err.message, details: err.errors });
    }

    const room = await roomRes.json();

    // Generate JWT
    const token = jwt.sign({
      td: TEAM_ID,
      rd: room.id,
      ud: req.user.id,
      u: req.user.displayName,
      role: req.user.isAdmin ? 'moderator' : 'attendee',
      exp: Math.floor(Date.now() / 1000) + 3600 // 1 hour
    }, DEVELOPER_KEY, { algorithm: 'HS256' });

    res.json({ token, roomUrl: `https://${TEAM_DOMAIN}.digitalsamba.com/${room.friendly_url}` });
  } catch (err) {
    console.error('Room setup failed:', err);
    res.status(500).json({ error: 'Failed to create room' });
  }
});

// Client: Fetch token and embed with error handling
async function joinRoom(roomSlug) {
  const res = await fetch('/api/room-token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ roomSlug })
  });

  if (!res.ok) {
    const err = await res.json();
    throw new Error(`Token fetch failed: ${err.error}`);
  }

  const { token, roomUrl } = await res.json();
  iframe.src = `${roomUrl}?token=${token}`;
}

Token refresh: Tokens have a fixed expiry (the exp claim). Generate a new token per session — don't cache tokens long-term. If a user's session outlasts the token, generate a fresh one and reload the iframe.

Role Assignment at Join Time

The JWT role claim sets the user's initial role when they enter the room (see jwt-tokens.md for all claims). You can also change roles dynamically after join using the SDK:

// Initial role is set in the JWT (e.g., role: 'attendee')
// Promote to speaker dynamically when they raise their hand.
// handRaised/handLowered carry { userId } only — use getUser() for the name.
sambaFrame.on('handRaised', (e) => {
  const { userId } = e.data;
  console.log(`${sambaFrame.getUser(userId)?.name ?? userId} raised hand — promoting to speaker`);
  sambaFrame.changeRole(userId, 'speaker');
});

// Demote back to attendee when hand is lowered
sambaFrame.on('handLowered', (e) => {
  sambaFrame.changeRole(e.data.userId, 'attendee');
});

Note: changeRole() requires the caller to have moderator permissions. The room must have the target role defined in its roles list.

Pattern 3: SDK-Controlled Room

Best for: Custom UIs, programmatic control

Production tip: Bundle the SDK into your app rather than fetching from npm/CDN at runtime. npm outages can break your app's availability.

import DigitalSambaEmbedded from '@digitalsamba/embedded-sdk';

// Use `root` for a container element the SDK injects an iframe into.
// `frame` is only for an <iframe> element you already placed yourself.
const sambaFrame = DigitalSambaEmbedded.createControl({
  url: roomUrl,
  root: document.getElementById('video-container')
});

// React to events
sambaFrame.on('userJoined', handleJoin);
sambaFrame.on('userLeft', handleLeave);
sambaFrame.on('recordingStarted', handleRecording);

// Control room (mute a specific user)
document.getElementById('mute-user').onclick = () => {
  sambaFrame.requestMute('user-id');
};

sambaFrame.load();

Pattern 4: Scheduled Meetings

Best for: Calendar integrations, booking systems

// Create room with time constraints.
// is_locked means arrivals wait for approval; lobby_message is what they see.
const meeting = await createRoom({
  friendly_url: `meeting-${Date.now()}`,
  is_locked: true,
  session_length: 60,
  lobby_message: 'The host will let you in shortly.'
});

// Generate invite tokens
const invites = participants.map(p => {
  const token = generateRoomToken(p, meeting.id);
  return {
    email: p.email,
    token,
    joinUrl: `https://${TEAM_DOMAIN}.digitalsamba.com/${meeting.friendly_url}?token=${token}`
  };
});

// Send invitations via email
await sendMeetingInvites(invites);

Pattern 5: Webinar Mode

Best for: One-to-many broadcasts

// Room settings for webinar
const webinarRoom = await createRoom({
  friendly_url: 'product-launch',
  default_role: 'attendee',
  roles: ['moderator', 'speaker', 'attendee'],
  chat_enabled: true,
  qa_enabled: true,
  raise_hand_enabled: true,
  video_on_join_enabled: false,
  audio_on_join_enabled: false
});

// Host gets moderator token
const hostToken = generateToken({ role: 'moderator', ... });

// Attendees get attendee token (view-only by default)
const attendeeToken = generateToken({ role: 'attendee', ... });

Pattern 6: Recording & Playback

// Server-side only — these calls use the developer key, never expose them to a browser
const API = 'https://api.digitalsamba.com/api/v1';

// Start recording programmatically
await fetch(`${API}/rooms/${roomId}/recordings/start`, {
  method: 'POST',
  headers: { 'Authorization': `Bearer ${DEVELOPER_KEY}` }
});

// Or from the client, via SDK (no developer key needed)
sambaFrame.startRecording();

// Later: List and download recordings
const recordings = await fetch(`${API}/recordings`, {
  headers: { 'Authorization': `Bearer ${DEVELOPER_KEY}` }
}).then(r => r.json());

for (const rec of recordings.data) {
  const downloadUrl = `${API}/recordings/${rec.id}/download`;
  // Store or process recording
}

Pattern 7: Online Learning Platform

Best for: LMS integrations, virtual classrooms, tutoring platforms, training systems

This pattern covers a complete online learning flow: creating per-class virtual rooms, assigning instructor/student roles, recording lessons for later playback, and tracking attendance via webhooks.

Server-side: Course Room Management (Node.js/Express)

const jwt = require('jsonwebtoken');

const DEVELOPER_KEY = process.env.DS_DEVELOPER_KEY;
const TEAM_ID = process.env.DS_TEAM_ID;
const TEAM_DOMAIN = process.env.DS_TEAM_DOMAIN; // e.g., "myschool"

// Create a virtual classroom for a course
app.post('/api/courses/:courseId/classroom', async (req, res) => {
  const { courseId } = req.params;
  const course = await db.courses.findById(courseId);

  const roomRes = await fetch('https://api.digitalsamba.com/api/v1/rooms', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${DEVELOPER_KEY}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      friendly_url: `class-${courseId}`,
      privacy: 'private',
      description: `Virtual classroom: ${course.title}`,
      max_participants: course.maxStudents + 5, // students + instructors + buffer
      session_length: course.durationMinutes,
      default_role: 'attendee',
      roles: ['moderator', 'speaker', 'attendee'],
      // Classroom settings
      chat_enabled: true,
      qa_enabled: true,
      recordings_enabled: true,
      screenshare_enabled: true,
      raise_hand_enabled: true,
      // Students join with camera/mic off to reduce noise
      video_on_join_enabled: false,
      audio_on_join_enabled: false
    })
  });

  if (!roomRes.ok) {
    const err = await roomRes.json();
    return res.status(roomRes.status).json({ error: err.message, details: err.errors });
  }

  const room = await roomRes.json();

  // Store room ID in your database
  await db.courses.update(courseId, { digitalSambaRoomId: room.id });

  res.json({ roomId: room.id, roomUrl: room.room_url });
});

// Generate join token based on user role in the course
app.get('/api/courses/:courseId/join', async (req, res) => {
  const { courseId } = req.params;
  const user = req.user; // From your auth middleware
  const course = await db.courses.findById(courseId);
  const enrollment = await db.enrollments.findOne({ courseId, userId: user.id });

  if (!enrollment) {
    return res.status(403).json({ error: 'Not enrolled in this course' });
  }

  // Map your app roles to Digital Samba roles
  const dsRole = enrollment.role === 'instructor' ? 'moderator'
               : enrollment.role === 'ta' ? 'speaker'
               : 'attendee';

  const token = jwt.sign({
    td: TEAM_ID,
    rd: course.digitalSambaRoomId,
    ud: user.id,
    u: user.displayName,
    role: dsRole,
    exp: Math.floor(Date.now() / 1000) + (course.durationMinutes * 60) + 900 // session + 15 min buffer
  }, DEVELOPER_KEY, { algorithm: 'HS256' });

  const roomUrl = `https://${TEAM_DOMAIN}.digitalsamba.com/class-${courseId}`;
  res.json({ token, joinUrl: `${roomUrl}?token=${token}` });
});

Client-side: Embedded Virtual Classroom (React)

import { useEffect, useRef, useState } from 'react';
import DigitalSambaEmbedded from '@digitalsamba/embedded-sdk';

function VirtualClassroom({ courseId }) {
  const containerRef = useRef(null);
  const sambaRef = useRef(null);
  const [status, setStatus] = useState('loading');
  const [participants, setParticipants] = useState([]);

  useEffect(() => {
    async function joinClassroom() {
      // Fetch join token from your backend
      const res = await fetch(`/api/courses/${courseId}/join`);
      if (!res.ok) {
        setStatus('error');
        return;
      }
      const { joinUrl } = await res.json();

      // Initialize SDK — `root` is a container the SDK injects an iframe into
      const sambaFrame = DigitalSambaEmbedded.createControl({
        url: joinUrl,
        root: containerRef.current
      });

      // Track attendance — usersUpdated carries the full roster as { users }
      sambaFrame.on('usersUpdated', (e) => {
        setParticipants(e.data.users.map(u => ({ id: u.id, name: u.name })));
      });

      // Connection status
      sambaFrame.on('frameLoaded', () => setStatus('connected'));
      sambaFrame.on('mediaConnectionFailed', () => setStatus('error'));
      sambaFrame.on('appError', () => setStatus('error'));

      sambaFrame.load({ reportErrors: true });
      sambaRef.current = sambaFrame;
    }

    joinClassroom();

    // The SDK has no destroy() — leave the session, then remove the injected iframe
    return () => {
      sambaRef.current?.leaveSession();
      sambaRef.current?.frame?.remove();
      sambaRef.current = null;
    };
  }, [courseId]);

  return (
    <div style={{ display: 'flex', height: '100vh' }}>
      {/* Video area */}
      <div
        ref={containerRef}
        style={{ flex: 1, position: 'relative' }}
      />
      {/* Attendance sidebar */}
      <aside style={{ width: 250, padding: 16, borderLeft: '1px solid #ddd' }}>
        <h3>Participants ({participants.length})</h3>
        <ul>
          {participants.map(p => <li key={p.id}>{p.name}</li>)}
        </ul>
      </aside>
    </div>
  );
}

Server-side: Attendance Tracking via Webhooks

// Set up a webhook to track student attendance automatically.
// First, register the webhook via API:
//   POST /api/v1/webhooks {
//     endpoint: "https://yourschool.com/ds-webhook",
//     events: ["participant_joined", "participant_left"],
//     authorization_header: "<a secret bearer token you choose>"
//   }
// Event names are snake_case — see api-reference.md "Webhook Events" for the
// full list, or call GET /api/v1/events for the current list for your team.

const WEBHOOK_TOKEN = process.env.DS_WEBHOOK_TOKEN;

app.post('/ds-webhook', (req, res) => {
  // Digital Samba sends the value you set as `authorization_header`
  if (req.get('Authorization') !== WEBHOOK_TOKEN) {
    return res.sendStatus(401);
  }

  const event = req.body;

  switch (event.event) {
    case 'participant_joined':
      db.attendance.create({
        sessionId: event.data.session_id,
        participantId: event.data.external_id, // Maps to your user ID (from JWT 'ud' claim)
        joinedAt: event.timestamp
      });
      break;

    case 'participant_left':
      db.attendance.update(
        { sessionId: event.data.session_id, participantId: event.data.external_id },
        { leftAt: event.timestamp }
      );
      break;

    default:
      console.log(`Unhandled webhook event: ${event.event}`);
  }

  res.sendStatus(200);
});

Retrieving Lesson Recordings

// After class ends, fetch recordings for student playback
app.get('/api/courses/:courseId/recordings', async (req, res) => {
  const course = await db.courses.findById(req.params.courseId);

  const recordings = await fetch(
    `https://api.digitalsamba.com/api/v1/recordings?limit=50`,
    { headers: { 'Authorization': `Bearer ${DEVELOPER_KEY}` } }
  ).then(r => r.json());

  // Filter to this room's recordings
  const courseRecordings = recordings.data.filter(r => r.room_id === course.digitalSambaRoomId);

  res.json(courseRecordings.map(r => ({
    id: r.id,
    duration: r.duration,
    createdAt: r.created_at,
    downloadUrl: `https://api.digitalsamba.com/api/v1/recordings/${r.id}/download`
  })));
});

Pattern 8: Playwright E2E Testing

Best for: Automated testing, demo recordings, CI/CD validation

Digital Samba apps involve async SDK loading and iframe interactions. Here's how to test reliably:

Playwright Config for Video Recording

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  timeout: 120000, // Video rooms need time to connect
  use: {
    actionTimeout: 30000,
    video: {
      mode: 'on',
      size: { width: 1920, height: 1080 }
    }
  },
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: true
  }
});

Testing Room Creation Flow

import { test, expect } from '@playwright/test';

test('create and join video room', async ({ page }) => {
  // Create room via UI
  await page.goto('/create');
  await page.fill('input[placeholder*="title"]', 'Test Interview');
  await page.fill('input[placeholder*="name"]', 'Test User');
  await page.click('button[type="submit"]');

  // Wait for room creation API response
  await expect(page.locator('text=Room Created')).toBeVisible();

  // Extract room code for joining
  const roomCode = await page.locator('.room-code').textContent();

  // Join room and wait for SDK connection
  await page.goto(`/room/${roomCode}`);

  // Wait for Digital Samba iframe to load
  await expect(page.frameLocator('iframe').locator('body')).toBeVisible({
    timeout: 15000
  });
});

Testing SDK Events

test('SDK emits connection events', async ({ page }) => {
  // Expose handler to capture SDK events
  const events: string[] = [];
  await page.exposeFunction('captureEvent', (name: string) => events.push(name));

  // Inject event listener before SDK loads
  await page.addInitScript(() => {
    window.addEventListener('message', (e) => {
      if (e.data?.type?.startsWith('digitalSamba:')) {
        (window as any).captureEvent(e.data.type);
      }
    });
  });

  await page.goto('/room/test-room?token=...');

  // Wait for connection
  await page.waitForTimeout(10000);

  expect(events).toContain('digitalSamba:userJoined');
});

Recording Demo Videos

// Slow down for watchable recordings
const SLOW_MO = 800;

test('demo recording - full flow', async ({ page }) => {
  await page.goto('/');
  await page.waitForTimeout(2000); // Hold for intro

  // Character-by-character typing for effect
  const input = page.locator('input[placeholder*="title"]');
  for (const char of 'Product Demo') {
    await input.type(char, { delay: 50 });
  }
  await page.waitForTimeout(SLOW_MO);

  // Continue flow with pauses for video clarity
  await page.click('button[type="submit"]');
  await page.waitForTimeout(3000); // Hold on result
});

Handling Iframe Permissions

// Grant camera/mic permissions for video testing
const context = await browser.newContext({
  permissions: ['camera', 'microphone'],
  viewport: { width: 1920, height: 1080 }
});

Troubleshooting & Diagnostics

API Error Reference

API errors return JSON with this structure:

{
  "error": "error_code",
  "message": "Human-readable description",
  "errors": {                    // Present on 422 validation errors
    "friendly_url": ["The friendly url has already been taken."],
    "roles": ["The roles field is required when default_role is set."]
  }
}
Code Meaning Common Causes
401 Unauthorized Missing/invalid Authorization header; expired developer key
403 Forbidden Key lacks permission for this endpoint; role-restricted action
404 Not Found Room UUID doesn't exist; room was deleted; typo in endpoint path
409 Conflict Recording already in progress; session already ended
422 Validation Error See common causes below
429 Rate Limited Too many requests; back off and retry with exponential delay

Common 422 validation errors by endpoint:

Endpoint Field Cause
POST /rooms friendly_url Already taken, or exceeds 32 characters
POST /rooms roles Required when default_role is set
POST /rooms privacy Must be "public" or "private"
POST /rooms session_length Must be between 1 and 1440 (minutes)
POST /rooms/{room}/recordings/start - No active session in the room

SDK Initialization Failure Diagnosis

When the SDK fails to load or connect, work through this checklist:

1. Secure context required — HTTPS only (except localhost / 127.0.0.1)

if (!window.isSecureContext) {
  console.error('Digital Samba requires HTTPS. Current origin:', location.origin);
}

2. Container element not found — DOM not ready or wrong selector

const container = document.getElementById('video-container');
if (!container) {
  console.error('Container element #video-container not found. Ensure DOM is ready.');
}

3. Missing iframe permissions — Required for camera/mic access

<!-- The SDK adds this automatically, but if you create the iframe manually: -->
<iframe allow="camera; microphone; display-capture; autoplay" ...>

4. Token issues — Expired, wrong team/room ID, or malformed

// Decode and inspect a JWT client-side (no verification)
function inspectToken(token) {
  try {
    const payload = JSON.parse(atob(token.split('.')[1]));
    const now = Math.floor(Date.now() / 1000);
    if (payload.exp && payload.exp < now) {
      console.error('Token expired', new Date(payload.exp * 1000));
    }
    console.log('Token claims:', { td: payload.td, rd: payload.rd, role: payload.role, exp: payload.exp });
    return payload;
  } catch (e) {
    console.error('Malformed token — cannot decode');
    return null;
  }
}

5. Room doesn't exist — Verify the room exists before loading the SDK

// Server-side: Verify room exists via API before generating a token
async function verifyRoomExists(roomId) {
  const response = await fetch(`https://api.digitalsamba.com/api/v1/rooms/${roomId}`, {
    headers: { 'Authorization': `Bearer ${process.env.DS_DEVELOPER_KEY}` }
  });

  if (response.status === 404) {
    console.error(`Room ${roomId} not found — it may have been deleted`);
    return null;
  }
  if (!response.ok) {
    console.error(`API error checking room: ${response.status}`);
    return null;
  }

  return response.json(); // Room exists, return details
}

6. Network / firewall blocking — Verify connectivity to Digital Samba servers

// Client-side: Check if the Digital Samba domain is reachable
async function checkConnectivity(teamDomain) {
  try {
    const response = await fetch(`https://${teamDomain}.digitalsamba.com`, {
      method: 'HEAD',
      mode: 'no-cors'
    });
    console.log('Digital Samba domain is reachable');
    return true;
  } catch (e) {
    console.error('Cannot reach Digital Samba servers — check network/firewall:', e.message);
    return false;
  }
}

SDK Diagnostic Event Listeners

Wire up error and diagnostic events before calling load():

// Application error — runtime errors from the embedded app.
// e.data is { code, message }; inspect e.data.code to branch on the cause.
sambaFrame.on('appError', (e) => {
  console.error(`App error [${e.data.code}]: ${e.data.message}`);
});

// Media connection to the server could not be established (network/firewall)
sambaFrame.on('mediaConnectionFailed', () => {
  console.error('Media connection failed — check network and firewall rules');
});

// Browser denied camera/microphone access
sambaFrame.on('mediaPermissionsFailed', () => {
  console.error('Media permissions denied — user must grant camera/mic access');
});

// Recording could not be started or was interrupted
sambaFrame.on('recordingFailed', (e) => {
  console.error('Recording failed:', e.data);
});

// Debug: log all events during development
sambaFrame.on('*', (e) => {
  console.debug('[DS Event]', e.type, e.data);
});

Setup errors are not events. Problems detected before the room connects — insecure context, invalid room URL, missing config, an <iframe> without an allow attribute — are logged to the console rather than emitted. Call sambaFrame.load({ reportErrors: true }) during development to make the SDK throw them instead, so they surface in your error tracking.

Full Diagnostic Initialization Example

A production-ready initializeRoom() that validates prerequisites, sets up error listeners, and detects timeouts:

async function initializeRoom({ url, containerId, onReady, onError }) {
  // 1. Validate secure context
  if (!window.isSecureContext) {
    onError?.('Secure context required — serve over HTTPS');
    return null;
  }

  // 2. Validate container exists
  const container = document.getElementById(containerId);
  if (!container) {
    onError?.(`Container element #${containerId} not found`);
    return null;
  }

  // 3. Create SDK instance
  const sambaFrame = DigitalSambaEmbedded.createControl({ url, root: container });

  // 4. Set up error listeners before load()
  sambaFrame.on('appError', (e) => {
    onError?.(`App error [${e.data?.code}]: ${e.data?.message}`);
  });

  sambaFrame.on('mediaConnectionFailed', () => {
    onError?.('Media connection failed — check network and firewall rules');
  });

  sambaFrame.on('mediaPermissionsFailed', () => {
    onError?.('Camera/microphone access was denied by the browser');
  });

  // 5. Detect load timeout
  let loaded = false;
  sambaFrame.on('frameLoaded', () => {
    loaded = true;
    onReady?.(sambaFrame);
  });

  // reportErrors surfaces setup problems (bad URL, insecure context) as thrown errors
  try {
    sambaFrame.load({ reportErrors: true });
  } catch (err) {
    onError?.(`Failed to load room: ${err.message}`);
    return null;
  }

  setTimeout(() => {
    if (!loaded) {
      onError?.('Timeout: iframe did not load within 15 seconds. Check URL and network.');
    }
  }, 15000);

  return sambaFrame;
}

// Usage
const frame = await initializeRoom({
  url: `https://${TEAM_DOMAIN}.digitalsamba.com/${roomId}?token=${token}`,
  containerId: 'video-container',
  onReady: (sf) => console.log('Room ready'),
  onError: (msg) => console.error('Room init failed:', msg)
});

Programmatic Failure Diagnosis

When initialization fails, run this diagnostic function to identify the root cause:

async function diagnoseRoomFailure({ url, token, containerId, teamDomain }) {
  const issues = [];

  // 1. Check secure context
  if (typeof window !== 'undefined' && !window.isSecureContext) {
    issues.push({
      check: 'secure_context',
      status: 'FAIL',
      message: `Page is not served over HTTPS. Origin: ${location.origin}`,
      fix: 'Serve your app over HTTPS (localhost is exempt)'
    });
  }

  // 2. Check container element
  const container = document.getElementById(containerId);
  if (!container) {
    issues.push({
      check: 'container',
      status: 'FAIL',
      message: `Element #${containerId} not found in DOM`,
      fix: 'Ensure the container element exists before initializing the SDK'
    });
  } else if (container.offsetHeight === 0 || container.offsetWidth === 0) {
    issues.push({
      check: 'container_size',
      status: 'WARN',
      message: `Container #${containerId} has zero dimensions (${container.offsetWidth}x${container.offsetHeight})`,
      fix: 'Set explicit width and height on the container element via CSS'
    });
  }

  // 3. Check token validity (client-side decode only, no signature verification)
  if (token) {
    try {
      const payload = JSON.parse(atob(token.split('.')[1]));
      const now = Math.floor(Date.now() / 1000);

      if (!payload.td) {
        issues.push({ check: 'token_td', status: 'FAIL', message: 'Token missing "td" (team ID) claim', fix: 'Include team ID in JWT payload' });
      }
      if (!payload.rd) {
        issues.push({ check: 'token_rd', status: 'FAIL', message: 'Token missing "rd" (room ID) claim', fix: 'Include room ID in JWT payload' });
      }
      if (payload.exp && payload.exp < now) {
        issues.push({ check: 'token_exp', status: 'FAIL', message: `Token expired at ${new Date(payload.exp * 1000).toISOString()}`, fix: 'Generate a fresh token with a future expiration' });
      }
      if (payload.nbf && payload.nbf > now) {
        issues.push({ check: 'token_nbf', status: 'FAIL', message: `Token not valid until ${new Date(payload.nbf * 1000).toISOString()}`, fix: 'Wait until the nbf time, or remove the nbf claim' });
      }
    } catch (e) {
      issues.push({ check: 'token_format', status: 'FAIL', message: 'Token is malformed — cannot decode JWT', fix: 'Verify the token is a valid JWT string' });
    }
  }

  // 4. Check network connectivity
  if (teamDomain) {
    try {
      await fetch(`https://${teamDomain}.digitalsamba.com`, { method: 'HEAD', mode: 'no-cors' });
    } catch (e) {
      issues.push({ check: 'network', status: 'FAIL', message: `Cannot reach ${teamDomain}.digitalsamba.com`, fix: 'Check network connectivity and firewall rules' });
    }
  }

  // 5. Check room exists via API (server-side only)
  // This check requires the developer key — call from your backend

  // Report
  if (issues.length === 0) {
    console.log('All diagnostic checks passed — issue may be server-side or transient');
  } else {
    console.group('Digital Samba Initialization Diagnosis');
    issues.forEach(i => {
      const icon = i.status === 'FAIL' ? '✗' : '⚠';
      console.error(`${icon} [${i.check}] ${i.message}\n  Fix: ${i.fix}`);
    });
    console.groupEnd();
  }

  return issues;
}

// Usage: Call when initialization fails
// diagnoseRoomFailure({ url: roomUrl, token, containerId: 'video-container', teamDomain: 'myteam' });

Error Events

The SDK reports runtime problems through three events. Only appError carries a code, and the set of codes is not published — log e.data.code and branch on the values you actually observe rather than hardcoding a list.

Event Payload Meaning Where to look
appError { code, message } Runtime error from the embedded app Log both fields; message is human-readable
mediaConnectionFailed - Media connection to the server could not be established Network, firewall, VPN, or restrictive proxy
mediaPermissionsFailed - Browser denied camera/microphone access Browser permission prompt, OS privacy settings, iframe allow attribute
recordingFailed { error } Recording could not start or was interrupted Check the room's recordings_enabled setting and the user's role permissions

Setup errors are raised before any of the above can fire, and are logged to the console unless you pass load({ reportErrors: true }), which makes them throw. Each has a name you can match on:

Name Meaning Fix
INSECURE_CONTEXT Page is not a secure context Serve over HTTPS (localhost is exempt)
INVALID_URL Room URL could not be parsed Check the URL passed as url
INVALID_INIT_CONFIG Neither a URL nor a team + room pair was supplied Pass url, or both team and room
ALLOW_ATTRIBUTE_MISSING You supplied a frame whose allow attribute is missing Add allow="camera; microphone; display-capture; autoplay"
UNKNOWN_TARGET The embedded app did not answer the handshake Verify the room URL loads directly in a browser tab

API Error Handling Wrapper

class DigitalSambaError extends Error {
  constructor(message, status, code, validationErrors) {
    super(message);
    this.status = status;
    this.code = code;
    this.validationErrors = validationErrors; // field-level errors from 422
  }
}

async function apiCall(endpoint, options = {}) {
  const url = `https://api.digitalsamba.com/api/v1${endpoint}`;

  let response;
  try {
    response = await fetch(url, {
      ...options,
      headers: {
        'Authorization': `Bearer ${DEVELOPER_KEY}`,
        'Content-Type': 'application/json',
        ...options.headers
      }
    });
  } catch (err) {
    throw new DigitalSambaError('Network error — check connectivity', 0, 'network_error');
  }

  if (!response.ok) {
    const body = await response.json().catch(() => ({}));

    switch (response.status) {
      case 401:
        throw new DigitalSambaError('Invalid or missing API key', 401, 'unauthorized');
      case 404:
        throw new DigitalSambaError(`Resource not found: ${endpoint}`, 404, 'not_found');
      case 422:
        throw new DigitalSambaError(
          body.message || 'Validation failed',
          422,
          'validation_error',
          body.errors // { field: ["error message", ...] }
        );
      case 429:
        throw new DigitalSambaError('Rate limited — retry after delay', 429, 'rate_limited');
      default:
        throw new DigitalSambaError(body.message || 'API error', response.status, body.error);
    }
  }

  // 204 No Content (e.g., DELETE responses)
  if (response.status === 204) return null;
  return response.json();
}