# 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 `
` 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`: ```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:}`. 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** ```bash 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`): ```ts 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: ```ts export interface WatchTogetherPayload { v: 1; type: 'watch_together'; session_id: string; } ``` Extend the `MessagePayload` union: ```ts export type MessagePayload = | TextMessagePayload | CallEventPayload | PollPayload | WhiteboardPayload | WatchTogetherPayload; ``` Extend `ParsedMessagePayload`: ```ts | { kind: 'watch_together'; sessionId: string; }; ``` In `parseMessagePayload`, after the `obj.type === 'whiteboard'` branch and BEFORE the text fallback, add: ```ts if (obj.type === 'watch_together') { const p = obj as Partial; 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`: ```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 { 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 { 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 { 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 { 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`: ```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`: ```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** ```bash 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`: ```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; } { const [session, setSession] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const pendingRef = useRef(null); const lastPushAtRef = useRef(0); const pushTimerRef = useRef(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** ```bash 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`: ```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 | null = null; function loadIframeApi(): Promise { 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(null); const playerRef = useRef(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 (

{t('app:watch.title', { defaultValue: 'Watch Together' })} {ended && ( {t('app:watch.ended', { defaultValue: 'Beendet' })} )}

{loading ? (

{t('app:watch.loading', { defaultValue: 'LΓ€dt…' })}

) : error ? (

{error}

) : !session ? (

{t('app:watch.missing', { defaultValue: 'Session nicht gefunden.' })}

) : (
)}
{isOwner ? t('app:watch.you_are_host', { defaultValue: 'Du steuerst die Wiedergabe.' }) : t('app:watch.you_are_guest', { defaultValue: 'Nur der Host kann steuern.' })} {session?.currentState.playing ? t('app:watch.playing', { defaultValue: 'β–Ά LΓ€uft' }) : t('app:watch.paused', { defaultValue: '⏸ Pause' })}
); } ``` 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** ```bash 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: ```ts 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**: ```ts import { createWatchTogetherPayload } from '../lib/conversationFeatures'; import { WatchTogetherModal } from '../components/WatchTogetherModal'; import { createWatchSession, parseYouTubeUrl } from '@chat-app/shared/chat'; ``` **B. State** β€” near `openWhiteboardId`: ```ts const [openWatchSessionId, setOpenWatchSessionId] = useState(null); const [watchDialogOpen, setWatchDialogOpen] = useState(false); const [watchUrl, setWatchUrl] = useState(''); const [watchError, setWatchError] = useState(null); const [watchCreating, setWatchCreating] = useState(false); ``` **C. Handler** β€” near `handleCreateWhiteboard`: ```ts 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): ```tsx ``` Inline icon at the bottom of the file: ```tsx function PlayBoxIcon(props: React.SVGProps) { return ( ); } ``` **E. URL-input dialog** β€” render at the page root (alongside other dialogs): ```tsx {watchDialogOpen && (
{ if (e.target === e.currentTarget) setWatchDialogOpen(false); }} >

{t('app:watch.dialog_title', { defaultValue: 'Watch Together starten' })}

{ 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 && (

{watchError}

)}
)} ``` **F. Modal mount** β€” alongside the Whiteboard modal mount at the page root: ```tsx {openWatchSessionId && ( setOpenWatchSessionId(null)} /> )} ``` **G. Open-event listener** β€” alongside the existing `chatapp:open-whiteboard` listener (added by P4B.T7 commit `18197a9`): ```ts 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: ```tsx if (parsed.kind === 'watch_together') { const id = parsed.sessionId; return (
Watch Together
YouTube synchronisiert ansehen
); } ``` - [ ] **Step 4: Typecheck** ``` pnpm --filter @chat-app/desktop typecheck ``` Expected: PASS. - [ ] **Step 5: Commit** ```bash 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 `