Skip to content

Video Chat Architecture

Technical implementation guide for the embedded video conferencing feature using Daily.co.

Status: Phase 1 (Core), Phase 2 (Specialized Layouts), & Phase 3 (Layout Preview Mode) are VERIFIED and COMMITTED (Jan 29, 2026).


Overview

On Book Pro uses Daily.co for "Headless Video"—embedding media streams directly into the React UI rather than using iframe embeds. This enables specialized, context-aware layouts for theatrical workflows (e.g., script panels alongside actor video feeds).


Directory Structure

src/features/video/
├── api/
│   ├── createRoom.ts        # Cloud Function wrapper
│   └── getRoomToken.ts      # Token generation wrapper
├── components/
│   ├── VideoView.tsx        # Main orchestration component
│   ├── VideoLobby.tsx       # Pre-join device selection
│   ├── VideoGrid.tsx        # Participant grid management
│   ├── VideoTile.tsx        # Individual participant tile
│   ├── VideoControls.tsx    # Mute, camera, share, leave
│   └── LayoutPreview.tsx    # Development-time mock preview
├── layouts/
│   ├── MeetingLayout.tsx    # Screen share focus mode
│   ├── OneOnOneLayout.tsx   # Equal side-by-side panels
│   ├── TableReadLayout.tsx  # 60/40 PDF + video split
│   └── RehearsalLayout.tsx  # Minimal video strip
├── store.ts                 # Zustand video state
├── permissions.ts           # RBAC integration
├── types.ts                 # Session and participant types
└── index.ts                 # Barrel exports

Core Patterns

JIT Room Management

Rooms are created on-demand to minimize costs and ensure security:

typescript
// Backend: Firebase Cloud Function
const room = await daily.rooms.create({
  name: `onbook-${projectId}-${timestamp}`,
  properties: {
    exp: Math.floor(Date.now() / 1000) + 7200, // 2-hour expiry
    enable_transcription: true
  }
});
  • Lifecycle: Rooms expire after 2 hours of inactivity
  • Naming: onbook-{projectId}-{timestamp} for uniqueness
  • Tokens: Stage Managers receive "owner" tokens; others receive join tokens

Isolated State Management

Video state is intentionally not in the global useAppStore:

typescript
// src/features/video/store.ts
export const useVideoStore = create<VideoState>((set) => ({
  isInSession: false,
  participants: [],
  selectedLayout: 'meeting',
  isAudioMuted: true,  // Privacy default
  isVideoMuted: false,
  // ... high-frequency updates
}));

Rationale: Video metadata (speaking state, media toggles) changes at 60fps. Isolating these updates prevents expensive global re-renders.

Component Orchestration

VideoView.tsx manages the state machine:

  1. Pre-Session: Shows VideoLobby for device selection
  2. In-Session: Wraps UI in DailyProvider from @daily-co/daily-react
  3. Layout Switching: Dynamically renders the selected layout component
  4. Cleanup: Calls daily.destroy() on unmount to release hardware
typescript
// Simplified orchestration flow
function VideoView() {
  const { isInSession, selectedLayout } = useVideoStore();
  
  if (!isInSession) {
    return <VideoLobby onJoin={handleJoin} />;
  }
  
  return (
    <DailyProvider callObject={callObject}>
      {getLayoutComponent(selectedLayout)}
    </DailyProvider>
  );
}

Lobby Implementation

Device Selection

The lobby provides "hair check" functionality before joining:

  • Camera Preview: Uses native getUserMedia (not Daily) for instant feedback
  • Device Enumeration: Populates dropdowns via navigator.mediaDevices.enumerateDevices()
  • Hot-Plug Support: Listens to devicechange events for live hardware updates

Mic Level Visualizer

Real-time audio feedback using Web Audio API:

typescript
// Simplified implementation
const audioContext = new AudioContext();
const analyser = audioContext.createAnalyser();
analyser.fftSize = 256;

// Connect mic stream to analyser
const source = audioContext.createMediaStreamSource(micStream);
source.connect(analyser);

// Animation loop with RMS calculation
function updateLevel() {
  const data = new Uint8Array(analyser.frequencyBinCount);
  analyser.getByteTimeDomainData(data);
  
  // Calculate RMS
  const rms = Math.sqrt(data.reduce((sum, val) => {
    const normalized = (val - 128) / 128;
    return sum + normalized * normalized;
  }, 0) / data.length);
  
  // Direct DOM manipulation for 60fps performance
  levelRef.current.style.width = `${rms * 800}%`;
  requestAnimationFrame(updateLevel);
}

Key Implementation Details:

  • Uses useRef for direct DOM manipulation (avoids React re-renders)
  • Time Domain analysis (getByteTimeDomainData) for vocal frequencies
  • RMS calculation scaled to 0-100% for visual representation

Hardware Switching State Machine

Device switching requires careful handling of hardware locks:

  1. Stop existing stream tracks
  2. Clear stream state (setStream(null))
  3. Wait 200ms for hardware release
  4. Request new device with exact constraint
  5. Fallback to any device on error
typescript
// Exact Fallback Chain pattern
try {
  stream = await navigator.mediaDevices.getUserMedia({
    video: { deviceId: { exact: selectedCameraId } }
  });
} catch {
  // Fallback to any available camera
  stream = await navigator.mediaDevices.getUserMedia({ video: true });
}

Layouts

Meeting Layout

  • Central screen share area
  • Floating participant bubbles at bottom
  • Active speaker highlighting

One-on-One Layout

  • 50/50 horizontal split
  • Equal-sized video tiles
  • Optimized for face-to-face conversations

Table Read Layout

  • 60/40 vertical split
  • Left panel: Script/PDF placeholder
  • Right panel: Scrolling video grid

Rehearsal Layout

  • Horizontal video strip (fixed height)
  • Remaining space for workspace tools
  • Minimal visual footprint

Layout Preview Mode

For development without live Daily sessions:

typescript
// MockVideoTile - gradient placeholder instead of live stream
function MockVideoTile({ name }: { name: string }) {
  return (
    <div className="mock-video-tile">
      <div className="gradient-placeholder" />
      <span className="participant-name">{name}</span>
    </div>
  );
}

// MockVideoGrid - generates N mock participants
function MockVideoGrid({ count }: { count: number }) {
  const participants = Array.from({ length: count }, (_, i) => ({
    id: `mock-${i}`,
    name: `Participant ${i + 1}`
  }));
  // ...
}

Toggle preview mode via a local state flag in VideoView.


Permissions

Integration with the RBAC system:

typescript
// src/features/video/permissions.ts
export function canStartMeeting(roleId: string): boolean {
  return ['owner', 'stage-manager', 'production-manager'].includes(roleId);
}

export function canJoinMeeting(roleId: string): boolean {
  // All project members can join
  return true;
}

Important: Use cloudMember.roleId (string) to look up roles, not a complete role object.


Workspace Configuration

typescript
// src/features/layout/workspace-config.ts
{
  id: 'video',
  label: 'Video',
  icon: 'videocam',
  route: '/video'
}

Workspace Visibility

Added to primaryTools for Stage Manager, Director, and Production Manager workspaces for immediate sidebar visibility.


Testing

Store Tests (tests/video/video-store.test.ts)

  • Session lifecycle
  • Participant updates
  • Hardware toggle states
  • Privacy defaults (audio starts muted)

Permission Tests (tests/video/video-permissions.test.ts)

  • Role-based meeting creation
  • Join permissions for all roles

Known Issues

Daily-React Deprecation Warning

The @daily-co/daily-react library may show an atomFamily deprecation warning. This is an internal dependency issue and doesn't affect functionality.

Browser Autoplay Policy

AudioContext may start suspended. It resumes automatically on user interaction (button clicks in lobby).


Infrastructure

Firebase Secrets

bash
firebase functions:secrets:set DAILY_API_KEY

Cloud Functions

  • createDailyRoom: Room provisioning
  • getDailyToken: Participant tokens
  • fetchTranscript: Post-meeting transcript retrieval

See Video API Reference for function signatures.