Files
ChatApp/docs/superpowers/plans/2026-05-16-phase5a-watch-together.md
T

40 KiB

Phase 5A — Watch-Together (YouTube)

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Per-conversation synchronized YouTube playback. Composer → "📺 Watch Together" → URL input → INSERT session row → inline bubble "Anna hat Watch-Together gestartet · [Beitreten]". Click Beitreten → fullscreen IFrame Player; the owner's play/pause/seek replicates to every joiner via realtime updates to current_state; joiners auto-reconcile when local drift > 2s.

Architecture:

  • Server: one new table public.conversation_watch_sessions (id, conversation_id, owner_user_id, video_id text, started_at, ended_at, current_state jsonb). RLS via existing is_conversation_member(cid). Added to supabase_realtime.
  • Bubble = a normal messages row whose plaintext payload is {v:1, type:'watch_together', session_id}. MessageBubble dispatches on parsed.kind === 'watch_together' to render the "Beitreten" tile.
  • Modal lazy-loads https://www.youtube.com/iframe_api on first open, instantiates window.YT.Player and renders into a <div ref> slot. Owner taps controls → useWatchSession.pushState({playing, positionSeconds}) updates current_state (throttled 500ms) and other joiners receive the change via realtime postgres_changes UPDATE → reconcile local player when delta > 2s.
  • Owner leaves (closes modal) → endSession() sets ended_at = now(), the bubble shows "Beendet" and disables Beitreten. Non-owner leave keeps the session open.

Tech Stack: PostgreSQL + RLS + Supabase Realtime; React 18; YouTube IFrame Player API (loaded lazily — no NPM dep); no new build-time deps.

Non-goals:

  • No 12h auto-cleanup cron (post-MVP, server-side).
  • No Twitch / Vimeo / self-hosted (YouTube only per spec).
  • No transfer-ownership UI (owner-only controls; if owner leaves the session is over).
  • No chat overlay in the player.

Pre-flight

  • Verify clean working tree on main

Run: cd "D:\Programmieren\ChatApp-Electron\chat-app" && git status Expected: clean.

  • Confirm tooling is green

Run: pnpm --filter @chat-app/shared typecheck && pnpm --filter @chat-app/desktop typecheck && pnpm --filter @chat-app/shared test -- --run Expected: all green; 42 shared tests pass.


Task 1: SQL migration — conversation_watch_sessions + RLS + realtime publication

Files:

  • Create: supabase/migrations/20260516000008_watch_together.sql

Idempotent.

  • Step 1: Write the SQL migration

Create supabase/migrations/20260516000008_watch_together.sql:

-- Phase 5A: per-conversation synchronized YouTube playback.
--
-- conversation_watch_sessions: one row per Watch-Together session. The bubble
-- in the chat is a normal `messages` row whose plaintext payload is
-- `{v:1, type:'watch_together', session_id:<id>}`. The owner's player drives
-- current_state (jsonb {playing, position_seconds, updated_at_ms}); other
-- joiners reconcile via realtime postgres_changes UPDATE when local drift
-- exceeds 2 seconds.

create table if not exists public.conversation_watch_sessions (
  id              uuid primary key default gen_random_uuid(),
  conversation_id uuid not null references public.conversations(id) on delete cascade,
  owner_user_id   uuid not null references auth.users(id) on delete cascade,
  video_id        text not null,
  started_at      timestamptz not null default now(),
  ended_at        timestamptz null,
  current_state   jsonb not null default '{"playing":false,"position_seconds":0,"updated_at_ms":0}'::jsonb,
  constraint conversation_watch_sessions_video_id_len check (length(video_id) between 1 and 64)
);

create index if not exists conversation_watch_sessions_conv_idx
  on public.conversation_watch_sessions(conversation_id, started_at desc);

alter table public.conversation_watch_sessions enable row level security;

drop policy if exists conversation_watch_sessions_select on public.conversation_watch_sessions;
drop policy if exists conversation_watch_sessions_insert on public.conversation_watch_sessions;
drop policy if exists conversation_watch_sessions_update on public.conversation_watch_sessions;

create policy conversation_watch_sessions_select
  on public.conversation_watch_sessions
  for select
  using (public.is_conversation_member(conversation_id));

create policy conversation_watch_sessions_insert
  on public.conversation_watch_sessions
  for insert
  with check (
    public.is_conversation_member(conversation_id)
    and owner_user_id = auth.uid()
  );

create policy conversation_watch_sessions_update
  on public.conversation_watch_sessions
  for update
  using (owner_user_id = auth.uid())
  with check (owner_user_id = auth.uid());

alter table public.conversation_watch_sessions replica identity full;

do $$
begin
  if not exists (
    select 1
      from pg_publication_tables
      where pubname = 'supabase_realtime'
        and schemaname = 'public'
        and tablename = 'conversation_watch_sessions'
  ) then
    execute 'alter publication supabase_realtime add table public.conversation_watch_sessions';
  end if;
end
$$;
  • Step 2: Commit + push to prod
cd "D:\Programmieren\ChatApp-Electron\chat-app"
git add supabase/migrations/20260516000008_watch_together.sql
git commit -m "feat(P5A.T1): conversation_watch_sessions table with RLS + realtime"
bash scripts/prod/push-migrations.sh watch_together

Task 2: Shared payload + wrappers + tests

Files:

  • Modify: packages/db-types/src/index.ts

  • Modify: packages/shared/src/chat/attachments.ts

  • Create: packages/shared/src/chat/watchTogether.ts

  • Create: packages/shared/src/chat/watchTogether.test.ts

  • Modify: packages/shared/src/chat/index.ts

  • Step 1: db-types entry

Append to packages/db-types/src/index.ts after the user_soundboards entry (added by P4C.T2 commit f981904):

conversation_watch_sessions: {
  Row: {
    id: string;
    conversation_id: string;
    owner_user_id: string;
    video_id: string;
    started_at: string;
    ended_at: string | null;
    current_state: { playing: boolean; position_seconds: number; updated_at_ms: number };
  };
  Insert: {
    id?: string;
    conversation_id: string;
    owner_user_id: string;
    video_id: string;
    started_at?: string;
    ended_at?: string | null;
    current_state?: { playing: boolean; position_seconds: number; updated_at_ms: number };
  };
  Update: {
    id?: string;
    conversation_id?: string;
    owner_user_id?: string;
    video_id?: string;
    started_at?: string;
    ended_at?: string | null;
    current_state?: { playing: boolean; position_seconds: number; updated_at_ms: number };
  };
  Relationships: [];
};
  • Step 2: Extend the payload union in attachments.ts

After WhiteboardPayload (added by P4B.T2 — grep Grep -n "WhiteboardPayload" packages/shared/src/chat/attachments.ts), add:

export interface WatchTogetherPayload {
  v: 1;
  type: 'watch_together';
  session_id: string;
}

Extend the MessagePayload union:

export type MessagePayload =
  | TextMessagePayload
  | CallEventPayload
  | PollPayload
  | WhiteboardPayload
  | WatchTogetherPayload;

Extend ParsedMessagePayload:

  | {
      kind: 'watch_together';
      sessionId: string;
    };

In parseMessagePayload, after the obj.type === 'whiteboard' branch and BEFORE the text fallback, add:

if (obj.type === 'watch_together') {
  const p = obj as Partial<WatchTogetherPayload>;
  const id = typeof p.session_id === 'string' && p.session_id.length > 0
    ? p.session_id
    : '';
  return { kind: 'watch_together', sessionId: id };
}
  • Step 3: Create the wrapper

Create packages/shared/src/chat/watchTogether.ts:

import type { AppSupabaseClient } from '../supabase/client';

export interface WatchSessionState {
  playing: boolean;
  positionSeconds: number;
  updatedAtMs: number;
}

export interface WatchSession {
  id: string;
  conversationId: string;
  ownerUserId: string;
  videoId: string;
  startedAt: string;
  endedAt: string | null;
  currentState: WatchSessionState;
}

// YouTube URL → 11-char video id. Returns null if no match.
export function parseYouTubeUrl(input: string): string | null {
  const s = input.trim();
  if (!s) return null;
  if (/^[A-Za-z0-9_-]{11}$/.test(s)) return s;
  const patterns = [
    /[?&]v=([A-Za-z0-9_-]{11})/,
    /youtu\.be\/([A-Za-z0-9_-]{11})/,
    /youtube\.com\/embed\/([A-Za-z0-9_-]{11})/,
    /youtube\.com\/shorts\/([A-Za-z0-9_-]{11})/,
  ];
  for (const re of patterns) {
    const m = re.exec(s);
    if (m && m[1]) return m[1];
  }
  return null;
}

export async function createWatchSession(
  client: AppSupabaseClient,
  params: { conversationId: string; videoId: string },
): Promise<WatchSession> {
  const { data: session } = await client.auth.getUser();
  if (!session.user) throw new Error('not authenticated');
  const { data, error } = await client
    .from('conversation_watch_sessions')
    .insert({
      conversation_id: params.conversationId,
      owner_user_id: session.user.id,
      video_id: params.videoId,
    })
    .select('id, conversation_id, owner_user_id, video_id, started_at, ended_at, current_state')
    .single();
  if (error) throw error;
  return mapRow(data);
}

export async function getWatchSession(
  client: AppSupabaseClient,
  sessionId: string,
): Promise<WatchSession | null> {
  const { data, error } = await client
    .from('conversation_watch_sessions')
    .select('id, conversation_id, owner_user_id, video_id, started_at, ended_at, current_state')
    .eq('id', sessionId)
    .maybeSingle();
  if (error) throw error;
  return data ? mapRow(data) : null;
}

export async function updateWatchSessionState(
  client: AppSupabaseClient,
  sessionId: string,
  state: WatchSessionState,
): Promise<void> {
  const { error } = await client
    .from('conversation_watch_sessions')
    .update({
      current_state: {
        playing: state.playing,
        position_seconds: state.positionSeconds,
        updated_at_ms: state.updatedAtMs,
      },
    })
    .eq('id', sessionId);
  if (error) throw error;
}

export async function endWatchSession(
  client: AppSupabaseClient,
  sessionId: string,
): Promise<void> {
  const { error } = await client
    .from('conversation_watch_sessions')
    .update({ ended_at: new Date().toISOString() })
    .eq('id', sessionId);
  if (error) throw error;
}

function mapRow(row: {
  id: string;
  conversation_id: string;
  owner_user_id: string;
  video_id: string;
  started_at: string;
  ended_at: string | null;
  current_state: unknown;
}): WatchSession {
  const raw = (row.current_state ?? {}) as Partial<{
    playing: boolean;
    position_seconds: number;
    updated_at_ms: number;
  }>;
  return {
    id: row.id,
    conversationId: row.conversation_id,
    ownerUserId: row.owner_user_id,
    videoId: row.video_id,
    startedAt: row.started_at,
    endedAt: row.ended_at,
    currentState: {
      playing: typeof raw.playing === 'boolean' ? raw.playing : false,
      positionSeconds: typeof raw.position_seconds === 'number' ? raw.position_seconds : 0,
      updatedAtMs: typeof raw.updated_at_ms === 'number' ? raw.updated_at_ms : 0,
    },
  };
}
  • Step 4: Tests

Create packages/shared/src/chat/watchTogether.test.ts:

import { describe, expect, it, vi } from 'vitest';

import {
  createWatchSession,
  getWatchSession,
  parseYouTubeUrl,
  updateWatchSessionState,
} from './watchTogether';

describe('parseYouTubeUrl', () => {
  it.each([
    ['https://www.youtube.com/watch?v=dQw4w9WgXcQ', 'dQw4w9WgXcQ'],
    ['https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=42', 'dQw4w9WgXcQ'],
    ['https://youtu.be/dQw4w9WgXcQ', 'dQw4w9WgXcQ'],
    ['https://youtu.be/dQw4w9WgXcQ?t=1', 'dQw4w9WgXcQ'],
    ['https://www.youtube.com/embed/dQw4w9WgXcQ', 'dQw4w9WgXcQ'],
    ['https://www.youtube.com/shorts/dQw4w9WgXcQ', 'dQw4w9WgXcQ'],
    ['https://m.youtube.com/watch?v=dQw4w9WgXcQ', 'dQw4w9WgXcQ'],
    ['dQw4w9WgXcQ', 'dQw4w9WgXcQ'],
  ])('extracts the id from %s', (url, expected) => {
    expect(parseYouTubeUrl(url)).toBe(expected);
  });

  it.each([
    '',
    '   ',
    'https://vimeo.com/123',
    'not a url',
    'short_id',
    'https://www.youtube.com/playlist?list=PL123',
  ])('returns null for %s', (input) => {
    expect(parseYouTubeUrl(input)).toBeNull();
  });
});

function makeClient(opts: {
  user?: { id: string } | null;
  insertReturn?: { data: unknown; error: unknown };
  selectReturn?: { data: unknown; error: unknown };
  updateReturn?: { error: unknown };
}): any {
  const single = vi.fn().mockResolvedValue(opts.insertReturn ?? { data: {}, error: null });
  const insertSelect = vi.fn().mockReturnValue({ single });
  const insertChain = vi.fn().mockReturnValue({ select: insertSelect });
  const maybeSingle = vi.fn().mockResolvedValue(opts.selectReturn ?? { data: null, error: null });
  const eqSelect = vi.fn().mockReturnValue({ maybeSingle });
  const selectChain = vi.fn().mockReturnValue({ eq: eqSelect });
  const eqUpdate = vi.fn().mockResolvedValue(opts.updateReturn ?? { error: null });
  const updateChain = vi.fn().mockReturnValue({ eq: eqUpdate });
  const from = vi.fn().mockReturnValue({
    insert: insertChain,
    select: selectChain,
    update: updateChain,
  });
  return {
    auth: { getUser: vi.fn().mockResolvedValue({ data: { user: opts.user ?? { id: 'u-1' } } }) },
    from,
  };
}

describe('createWatchSession', () => {
  it('inserts row + maps response to camelCase', async () => {
    const client = makeClient({
      insertReturn: {
        data: {
          id: 'w-1',
          conversation_id: 'c-1',
          owner_user_id: 'u-1',
          video_id: 'dQw4w9WgXcQ',
          started_at: '2026-05-16T00:00:00Z',
          ended_at: null,
          current_state: { playing: false, position_seconds: 0, updated_at_ms: 0 },
        },
        error: null,
      },
    });
    const out = await createWatchSession(client, { conversationId: 'c-1', videoId: 'dQw4w9WgXcQ' });
    expect(out.id).toBe('w-1');
    expect(out.ownerUserId).toBe('u-1');
    expect(out.videoId).toBe('dQw4w9WgXcQ');
    expect(out.endedAt).toBeNull();
    expect(out.currentState.playing).toBe(false);
  });
});

describe('getWatchSession', () => {
  it('returns null when row not found', async () => {
    const client = makeClient({ selectReturn: { data: null, error: null } });
    const out = await getWatchSession(client, 'w-missing');
    expect(out).toBeNull();
  });

  it('coerces missing current_state fields to safe defaults', async () => {
    const client = makeClient({
      selectReturn: {
        data: {
          id: 'w-1',
          conversation_id: 'c-1',
          owner_user_id: 'u-1',
          video_id: 'dQw4w9WgXcQ',
          started_at: '2026-05-16T00:00:00Z',
          ended_at: null,
          current_state: {},
        },
        error: null,
      },
    });
    const out = await getWatchSession(client, 'w-1');
    expect(out?.currentState).toEqual({ playing: false, positionSeconds: 0, updatedAtMs: 0 });
  });
});

describe('updateWatchSessionState', () => {
  it('does not throw on success', async () => {
    const client = makeClient({ updateReturn: { error: null } });
    await expect(
      updateWatchSessionState(client, 'w-1', {
        playing: true,
        positionSeconds: 42.5,
        updatedAtMs: Date.now(),
      }),
    ).resolves.toBeUndefined();
  });
});
  • Step 5: Re-export

Append to packages/shared/src/chat/index.ts:

export * from './watchTogether';
  • Step 6: Test + typecheck
pnpm --filter @chat-app/shared test -- --run watchTogether
pnpm --filter @chat-app/shared typecheck
pnpm --filter @chat-app/shared test -- --run

Expected: full suite ~54/54 (42 baseline + ~12 new).

  • Step 7: Commit
git add packages/db-types/src/index.ts packages/shared/src/chat/attachments.ts packages/shared/src/chat/watchTogether.ts packages/shared/src/chat/watchTogether.test.ts packages/shared/src/chat/index.ts
git commit -m "feat(P5A.T2): WatchTogetherPayload + wrappers + parseYouTubeUrl + tests"

Task 3: useWatchSession hook — initial fetch + realtime + push helpers

Files:

  • Create: apps/desktop/src/hooks/useWatchSession.ts

  • Step 1: Write the hook

Create apps/desktop/src/hooks/useWatchSession.ts:

import { useCallback, useEffect, useRef, useState } from 'react';

import {
  endWatchSession,
  getWatchSession,
  updateWatchSessionState,
  type WatchSession,
  type WatchSessionState,
} from '@chat-app/shared/chat';

import { supabase } from '../lib/supabase';

const PUSH_THROTTLE_MS = 500;

export function useWatchSession(sessionId: string | null): {
  session: WatchSession | null;
  loading: boolean;
  error: string | null;
  pushState: (state: WatchSessionState) => void;
  endSession: () => Promise<void>;
} {
  const [session, setSession] = useState<WatchSession | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const pendingRef = useRef<WatchSessionState | null>(null);
  const lastPushAtRef = useRef<number>(0);
  const pushTimerRef = useRef<number | null>(null);

  useEffect(() => {
    if (!sessionId) {
      setSession(null);
      setLoading(false);
      setError(null);
      return;
    }
    let cancelled = false;
    void (async () => {
      try {
        setLoading(true);
        setError(null);
        const fresh = await getWatchSession(supabase, sessionId);
        if (!cancelled) {
          setSession(fresh);
          setLoading(false);
        }
      } catch (err) {
        if (!cancelled) {
          setLoading(false);
          setError(err instanceof Error ? err.message : 'failed to load session');
        }
      }
    })();

    const channel = supabase
      .channel('watch_session:' + sessionId)
      .on(
        'postgres_changes',
        {
          event: 'UPDATE',
          schema: 'public',
          table: 'conversation_watch_sessions',
          filter: 'id=eq.' + sessionId,
        },
        (payload) => {
          const row = payload.new as {
            id?: string;
            ended_at?: string | null;
            current_state?: unknown;
          } | null;
          if (!row?.id) return;
          const raw = (row.current_state ?? {}) as Partial<{
            playing: boolean;
            position_seconds: number;
            updated_at_ms: number;
          }>;
          setSession((cur) => {
            if (!cur) return cur;
            return {
              ...cur,
              endedAt: row.ended_at ?? null,
              currentState: {
                playing: typeof raw.playing === 'boolean' ? raw.playing : cur.currentState.playing,
                positionSeconds:
                  typeof raw.position_seconds === 'number'
                    ? raw.position_seconds
                    : cur.currentState.positionSeconds,
                updatedAtMs:
                  typeof raw.updated_at_ms === 'number'
                    ? raw.updated_at_ms
                    : cur.currentState.updatedAtMs,
              },
            };
          });
        },
      )
      .subscribe();

    return () => {
      cancelled = true;
      void supabase.removeChannel(channel);
      if (pushTimerRef.current !== null) {
        window.clearTimeout(pushTimerRef.current);
        pushTimerRef.current = null;
      }
    };
  }, [sessionId]);

  const pushState = useCallback((state: WatchSessionState) => {
    if (!sessionId) return;
    pendingRef.current = state;
    const now = Date.now();
    const elapsed = now - lastPushAtRef.current;
    if (elapsed >= PUSH_THROTTLE_MS) {
      lastPushAtRef.current = now;
      const toPush = pendingRef.current;
      pendingRef.current = null;
      void updateWatchSessionState(supabase, sessionId, toPush).catch((err) => {
        console.warn('updateWatchSessionState failed', err);
      });
      return;
    }
    if (pushTimerRef.current !== null) window.clearTimeout(pushTimerRef.current);
    pushTimerRef.current = window.setTimeout(() => {
      pushTimerRef.current = null;
      const toPush = pendingRef.current;
      if (!toPush) return;
      pendingRef.current = null;
      lastPushAtRef.current = Date.now();
      void updateWatchSessionState(supabase, sessionId, toPush).catch((err) => {
        console.warn('updateWatchSessionState trailing failed', err);
      });
    }, PUSH_THROTTLE_MS - elapsed);
  }, [sessionId]);

  const endSession = useCallback(async () => {
    if (!sessionId) return;
    await endWatchSession(supabase, sessionId);
  }, [sessionId]);

  return { session, loading, error, pushState, endSession };
}
  • Step 2: Typecheck
pnpm --filter @chat-app/desktop typecheck

Expected: PASS.

  • Step 3: Commit
git add apps/desktop/src/hooks/useWatchSession.ts
git commit -m "feat(P5A.T3): useWatchSession hook with realtime + throttled push"

Task 4: WatchTogetherModal — fullscreen YouTube player + drift reconcile

Files:

  • Create: apps/desktop/src/components/WatchTogetherModal.tsx

  • Step 1: Write the component

Create apps/desktop/src/components/WatchTogetherModal.tsx:

import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';

import { useAuth } from '../context/AuthContext';
import { useWatchSession } from '../hooks/useWatchSession';
import { XIcon } from './icons';

// Minimal subset of the YouTube IFrame Player API surface we use.
interface YTPlayer {
  playVideo: () => void;
  pauseVideo: () => void;
  seekTo: (seconds: number, allowSeekAhead: boolean) => void;
  getCurrentTime: () => number;
  getPlayerState: () => number;
  destroy: () => void;
}

interface YTPlayerOptions {
  width: string | number;
  height: string | number;
  videoId: string;
  playerVars?: { autoplay?: 0 | 1; controls?: 0 | 1; modestbranding?: 0 | 1 };
  events?: {
    onReady?: (ev: { target: YTPlayer }) => void;
    onStateChange?: (ev: { data: number; target: YTPlayer }) => void;
  };
}

interface YTNamespace {
  Player: new (elementId: string | HTMLElement, opts: YTPlayerOptions) => YTPlayer;
  PlayerState: { UNSTARTED: -1; ENDED: 0; PLAYING: 1; PAUSED: 2; BUFFERING: 3; CUED: 5 };
}

declare global {
  interface Window {
    YT?: YTNamespace;
    onYouTubeIframeAPIReady?: () => void;
  }
}

const IFRAME_API_URL = 'https://www.youtube.com/iframe_api';
let apiPromise: Promise<YTNamespace> | null = null;

function loadIframeApi(): Promise<YTNamespace> {
  if (apiPromise) return apiPromise;
  apiPromise = new Promise((resolve, reject) => {
    if (typeof window === 'undefined') {
      reject(new Error('no window'));
      return;
    }
    if (window.YT?.Player) {
      resolve(window.YT);
      return;
    }
    const prev = window.onYouTubeIframeAPIReady;
    window.onYouTubeIframeAPIReady = () => {
      try {
        prev?.();
      } catch {
        /* ignore */
      }
      if (window.YT?.Player) resolve(window.YT);
      else reject(new Error('YT namespace missing after ready'));
    };
    const existing = document.querySelector(
      'script[src="' + IFRAME_API_URL + '"]',
    );
    if (existing) return;
    const tag = document.createElement('script');
    tag.src = IFRAME_API_URL;
    tag.async = true;
    document.head.appendChild(tag);
  });
  return apiPromise;
}

interface Props {
  sessionId: string;
  onClose: () => void;
}

const DRIFT_THRESHOLD_SECONDS = 2;

export function WatchTogetherModal({ sessionId, onClose }: Props) {
  const { t } = useTranslation();
  const { session, pushState, endSession, error, loading } = useWatchSession(sessionId);
  const { session: auth } = useAuth();
  const mountRef = useRef<HTMLDivElement | null>(null);
  const playerRef = useRef<YTPlayer | null>(null);
  const [playerReady, setPlayerReady] = useState(false);
  const ownerId = session?.ownerUserId ?? null;
  const isOwner = !!auth?.user.id && auth.user.id === ownerId;
  const ended = !!session?.endedAt;

  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      if (e.key === 'Escape') onClose();
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [onClose]);

  useEffect(() => {
    if (!session?.videoId || !mountRef.current) return;
    if (playerRef.current) return;
    let disposed = false;
    void loadIframeApi().then((YT) => {
      if (disposed || !mountRef.current) return;
      playerRef.current = new YT.Player(mountRef.current, {
        width: '100%',
        height: '100%',
        videoId: session.videoId,
        playerVars: { autoplay: 1, controls: isOwner ? 1 : 0, modestbranding: 1 },
        events: {
          onReady: () => setPlayerReady(true),
          onStateChange: (ev) => {
            if (!isOwner) return;
            const playing = ev.data === YT.PlayerState.PLAYING;
            const pos = ev.target.getCurrentTime();
            pushState({ playing, positionSeconds: pos, updatedAtMs: Date.now() });
          },
        },
      });
    }).catch((err) => {
      console.error('YouTube IFrame API failed', err);
    });
    return () => {
      disposed = true;
      try {
        playerRef.current?.destroy();
      } catch {
        /* ignore */
      }
      playerRef.current = null;
    };
  }, [session?.videoId, isOwner, pushState]);

  // Owner-side heartbeat: every second while the modal is open, push the
  // current player state so joiners always have a fresh reference point.
  useEffect(() => {
    if (!isOwner || !playerReady) return;
    const id = window.setInterval(() => {
      const p = playerRef.current;
      if (!p) return;
      try {
        const state = p.getPlayerState();
        const playing = state === window.YT?.PlayerState.PLAYING;
        pushState({
          playing,
          positionSeconds: p.getCurrentTime(),
          updatedAtMs: Date.now(),
        });
      } catch {
        /* ignore */
      }
    }, 1000);
    return () => window.clearInterval(id);
  }, [isOwner, playerReady, pushState]);

  // Joiner-side reconcile: project the remote position forward by the time
  // since it was sent; seek the local player if drift > threshold or if the
  // playing flag mismatches.
  useEffect(() => {
    if (isOwner || !playerReady || !session) return;
    const p = playerRef.current;
    if (!p) return;
    const remoteAgeSec = Math.max(0, (Date.now() - session.currentState.updatedAtMs) / 1000);
    const projectedRemote = session.currentState.playing
      ? session.currentState.positionSeconds + remoteAgeSec
      : session.currentState.positionSeconds;
    let local = 0;
    try {
      local = p.getCurrentTime();
    } catch {
      return;
    }
    if (Math.abs(local - projectedRemote) > DRIFT_THRESHOLD_SECONDS) {
      try {
        p.seekTo(projectedRemote, true);
      } catch {
        /* ignore */
      }
    }
    try {
      const state = p.getPlayerState();
      const localPlaying = state === window.YT?.PlayerState.PLAYING;
      if (session.currentState.playing && !localPlaying) {
        p.playVideo();
      } else if (!session.currentState.playing && localPlaying) {
        p.pauseVideo();
      }
    } catch {
      /* ignore */
    }
  }, [isOwner, playerReady, session]);

  const handleClose = async () => {
    if (isOwner && !ended) {
      try {
        await endSession();
      } catch (err) {
        console.warn('endSession failed', err);
      }
    }
    onClose();
  };

  return (
    <div
      role="dialog"
      aria-modal="true"
      aria-label={t('app:watch.title', { defaultValue: 'Watch Together' })}
      className="fixed inset-0 z-[80] flex flex-col bg-black"
    >
      <header className="flex shrink-0 items-center justify-between border-b border-line/40 bg-surface-2 px-4 py-2">
        <h2 className="font-display text-sm font-semibold text-fg">
          {t('app:watch.title', { defaultValue: 'Watch Together' })}
          {ended && (
            <span className="ml-2 rounded-md bg-rose-500/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-rose-600 dark:text-rose-300">
              {t('app:watch.ended', { defaultValue: 'Beendet' })}
            </span>
          )}
        </h2>
        <button
          type="button"
          onClick={() => void handleClose()}
          aria-label={t('app:watch.close', { defaultValue: 'Schließen' })}
          className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg"
        >
          <XIcon className="h-3.5 w-3.5" />
        </button>
      </header>

      <div className="flex min-h-0 flex-1 items-center justify-center overflow-hidden bg-black p-4">
        {loading ? (
          <p className="text-sm text-fg-muted">
            {t('app:watch.loading', { defaultValue: 'Lädt…' })}
          </p>
        ) : error ? (
          <p className="text-sm text-rose-400">{error}</p>
        ) : !session ? (
          <p className="text-sm text-fg-muted">
            {t('app:watch.missing', { defaultValue: 'Session nicht gefunden.' })}
          </p>
        ) : (
          <div className="aspect-video w-full max-w-5xl">
            <div ref={mountRef} className="h-full w-full" />
          </div>
        )}
      </div>

      <footer className="flex shrink-0 items-center justify-between gap-3 border-t border-line/40 bg-surface-2 px-4 py-2 text-xs text-fg-muted">
        <span>
          {isOwner
            ? t('app:watch.you_are_host', { defaultValue: 'Du steuerst die Wiedergabe.' })
            : t('app:watch.you_are_guest', { defaultValue: 'Nur der Host kann steuern.' })}
        </span>
        <span>
          {session?.currentState.playing
            ? t('app:watch.playing', { defaultValue: '▶ Läuft' })
            : t('app:watch.paused', { defaultValue: '⏸ Pause' })}
        </span>
      </footer>
    </div>
  );
}

If the project's tsconfig rejects the inline declare global, move the Window augmentation to a .d.ts next to the file.

  • Step 2: Typecheck
pnpm --filter @chat-app/desktop typecheck

Expected: PASS.

  • Step 3: Commit
git add apps/desktop/src/components/WatchTogetherModal.tsx
git commit -m "feat(P5A.T4): WatchTogetherModal — YouTube IFrame + drift reconcile"

Task 5: Composer button + create flow + bubble dispatch + open-event listener

Files:

  • Modify: apps/desktop/src/lib/conversationFeatures.ts

  • Modify: apps/desktop/src/pages/ConversationPage.tsx

  • Modify: apps/desktop/src/components/MessageBubble.tsx

  • Step 1: Add the payload helper

Read apps/desktop/src/lib/conversationFeatures.ts. After the existing createWhiteboardPayload (P4B.T6 commit 383245e), append:

export function createWatchTogetherPayload(sessionId: string): string {
  return serializeMessagePayload({
    v: 1,
    type: 'watch_together',
    session_id: sessionId,
  });
}

Add WatchTogetherPayload to the same @chat-app/shared/chat import that brings in WhiteboardPayload. serializeMessagePayload should already be imported (it's used by createWhiteboardPayload).

  • Step 2: Wire ConversationPage

In apps/desktop/src/pages/ConversationPage.tsx:

A. Imports:

import { createWatchTogetherPayload } from '../lib/conversationFeatures';
import { WatchTogetherModal } from '../components/WatchTogetherModal';
import { createWatchSession, parseYouTubeUrl } from '@chat-app/shared/chat';

B. State — near openWhiteboardId:

const [openWatchSessionId, setOpenWatchSessionId] = useState<string | null>(null);
const [watchDialogOpen, setWatchDialogOpen] = useState(false);
const [watchUrl, setWatchUrl] = useState('');
const [watchError, setWatchError] = useState<string | null>(null);
const [watchCreating, setWatchCreating] = useState(false);

C. Handler — near handleCreateWhiteboard:

const handleStartWatchTogether = useCallback(async () => {
  if (!id) return;
  const videoId = parseYouTubeUrl(watchUrl);
  if (!videoId) {
    setWatchError('Ungültige YouTube-URL.');
    return;
  }
  setWatchCreating(true);
  setWatchError(null);
  try {
    const ws = await createWatchSession(supabase, { conversationId: id, videoId });
    const payload = createWatchTogetherPayload(ws.id);
    await send(payload, [], replyTo?.id ?? null);
    setReplyTo(null);
    setStickToBottom(true);
    setWatchDialogOpen(false);
    setWatchUrl('');
    setOpenWatchSessionId(ws.id);
  } catch (err: unknown) {
    setWatchError(err instanceof Error ? err.message : 'Konnte Watch-Together nicht starten');
  } finally {
    setWatchCreating(false);
  }
}, [id, watchUrl, send, replyTo?.id]);

D. Composer button — immediately after the existing whiteboard button (search WhiteboardIcon to find it):

<button
  type="button"
  onClick={() => setWatchDialogOpen(true)}
  title={t('app:composer.watch_together', { defaultValue: 'Watch Together' })}
  aria-label={t('app:composer.watch_together', { defaultValue: 'Watch Together' })}
  className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-md text-fg-muted transition hover:bg-surface-3 hover:text-fg"
>
  <PlayBoxIcon className="h-4 w-4" />
</button>

Inline icon at the bottom of the file:

function PlayBoxIcon(props: React.SVGProps<SVGSVGElement>) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}
         strokeLinecap="round" strokeLinejoin="round" {...props}>
      <rect x="3" y="4" width="18" height="14" rx="2" />
      <path d="M10 9l5 3-5 3V9z" fill="currentColor" stroke="none" />
    </svg>
  );
}

E. URL-input dialog — render at the page root (alongside other dialogs):

{watchDialogOpen && (
  <div
    role="dialog"
    aria-modal="true"
    className="fixed inset-0 z-[70] flex items-center justify-center bg-ink-950/90 p-6"
    onClick={(e) => {
      if (e.target === e.currentTarget) setWatchDialogOpen(false);
    }}
  >
    <div className="w-full max-w-md rounded-2xl border border-line bg-surface-2 p-5 shadow-xl">
      <h2 className="mb-3 font-display text-lg font-semibold text-fg">
        {t('app:watch.dialog_title', { defaultValue: 'Watch Together starten' })}
      </h2>
      <input
        type="url"
        placeholder="https://youtu.be/..."
        value={watchUrl}
        onChange={(e) => { setWatchUrl(e.target.value); setWatchError(null); }}
        className="mb-2 w-full rounded-md border border-line bg-surface-3 px-3 py-2 text-sm text-fg placeholder-fg-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/40"
      />
      {watchError && (
        <p className="mb-2 text-xs text-rose-400">{watchError}</p>
      )}
      <div className="mt-3 flex items-center justify-end gap-2">
        <button
          type="button"
          onClick={() => { setWatchDialogOpen(false); setWatchUrl(''); setWatchError(null); }}
          className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-1.5 text-xs font-medium text-fg-muted transition hover:bg-surface"
        >
          {t('app:watch.cancel', { defaultValue: 'Abbrechen' })}
        </button>
        <button
          type="button"
          onClick={() => void handleStartWatchTogether()}
          disabled={watchCreating || !watchUrl.trim()}
          className="cursor-pointer rounded-md bg-accent px-4 py-1.5 text-xs font-semibold text-accent-fg transition hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-50"
        >
          {watchCreating
            ? t('app:watch.starting', { defaultValue: 'Startet…' })
            : t('app:watch.start', { defaultValue: 'Starten' })}
        </button>
      </div>
    </div>
  </div>
)}

F. Modal mount — alongside the Whiteboard modal mount at the page root:

{openWatchSessionId && (
  <WatchTogetherModal
    sessionId={openWatchSessionId}
    onClose={() => setOpenWatchSessionId(null)}
  />
)}

G. Open-event listener — alongside the existing chatapp:open-whiteboard listener (added by P4B.T7 commit 18197a9):

useEffect(() => {
  const onOpen = (e: Event) => {
    const detail = (e as CustomEvent<{ id?: string }>).detail;
    if (detail?.id) setOpenWatchSessionId(detail.id);
  };
  window.addEventListener('chatapp:open-watch-together', onOpen);
  return () => window.removeEventListener('chatapp:open-watch-together', onOpen);
}, []);
  • Step 3: Wire MessageBubble

In apps/desktop/src/components/MessageBubble.tsx, find the existing parsed.kind === 'whiteboard' branch. Just BEFORE it, add:

if (parsed.kind === 'watch_together') {
  const id = parsed.sessionId;
  return (
    <div className="flex items-center gap-3 rounded-2xl border border-accent/30 bg-accent/5 px-4 py-3">
      <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-accent/20 text-accent">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}
             strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
          <rect x="3" y="4" width="18" height="14" rx="2" />
          <path d="M10 9l5 3-5 3V9z" fill="currentColor" stroke="none" />
        </svg>
      </div>
      <div className="min-w-0 flex-1">
        <div className="text-sm font-semibold text-fg">Watch Together</div>
        <div className="text-xs text-fg-muted">YouTube synchronisiert ansehen</div>
      </div>
      <button
        type="button"
        onClick={() => {
          window.dispatchEvent(
            new CustomEvent('chatapp:open-watch-together', { detail: { id } }),
          );
        }}
        disabled={!id}
        className="cursor-pointer rounded-md bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-50"
      >
        Beitreten
      </button>
    </div>
  );
}
  • Step 4: Typecheck
pnpm --filter @chat-app/desktop typecheck

Expected: PASS.

  • Step 5: Commit
git add apps/desktop/src/lib/conversationFeatures.ts apps/desktop/src/pages/ConversationPage.tsx apps/desktop/src/components/MessageBubble.tsx
git commit -m "feat(P5A.T5): composer Watch-Together button + bubble dispatch"

Final gate

  • Step 1: Typecheck both packages

Run: pnpm --filter @chat-app/shared typecheck && pnpm --filter @chat-app/desktop typecheck Expected: both PASS.

  • Step 2: Run shared tests

Run: pnpm --filter @chat-app/shared test -- --run Expected: PASS — ~54 tests (42 baseline + ~12 new).

  • Step 3: Verify no uncommitted changes

Run: git status Expected: clean.

  • Step 4: Report

Report: "Phase 5A (Watch-Together) code-complete on main; migration applied to prod. Restart dev → composer → 📺 → paste a YouTube URL → bubble appears + modal opens with the video. On another account in the same conversation, click Beitreten → modal opens; play/pause/seek on the owner side propagates within ~1s; drift > 2s auto-reconciles. Owner-close ends the session (bubble shows 'Beendet'). No release unless you say so."


Self-review (resolved inline)

  1. Spec coverage (docs/superpowers/specs/2026-05-16-fifteen-features-design.md lines 119-124):

    • "New table conversation_watch_sessions (id, conversation_id, owner_user_id, video_id, started_at, ended_at, current_state jsonb). current_state = { playing, position_seconds, updated_at_ms }" → T1
    • "Composer → 📺 Watch Together → modal with URL input → extract video-id → INSERT row → inline bubble" → T2 parseYouTubeUrl, T5 dialog + INSERT + send
    • "Click Beitreten → fullscreen modal with YouTube IFrame Player API. Controls (play/pause/seek) by owner are broadcast" → T4 owner state-change push + 1s heartbeat
    • "Other clients reconcile their local player when drift > 2 seconds" → T4 joiner reconcile (DRIFT_THRESHOLD_SECONDS = 2)
    • "IFrame API loaded lazily via <script src=...>. No build-time dep" → T4 loadIframeApi()
    • "Auto-end after 12h inactivity (cron)" → out of scope per non-goals; spec calls it server-side
    • "Owner-leave sets ended_at. Non-owner leave keeps session open" → T4 handleClose is owner-only endSession
  2. Placeholders: none.

  3. Type consistency:

    • WatchSession (camelCase) ↔ conversation_watch_sessions (snake_case): isolated in mapRow.
    • WatchSessionState.positionSeconds/updatedAtMsposition_seconds/updated_at_ms: translated at the wrapper boundary.
    • WatchTogetherPayload.session_idParsedMessagePayload.sessionId: matches WhiteboardPayload.whiteboard_idwhiteboardId pattern.
  4. Realtime quirk: owner's own pushes echo back via realtime. Owner-side reconcile effect is gated if (isOwner) return; so only joiners apply reconcile.

  5. Push throttle vs heartbeat: owner heartbeat 1Hz; pushState throttles to 2Hz max. Bandwidth ~2-4 KB/s during playback.