diff --git a/docs/superpowers/plans/2026-05-16-phase5a-watch-together.md b/docs/superpowers/plans/2026-05-16-phase5a-watch-together.md
new file mode 100644
index 0000000..9895cec
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-16-phase5a-watch-together.md
@@ -0,0 +1,1229 @@
+# 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.' })}
+
+ ) : (
+
+ )}
+
+
+
+
+ );
+}
+```
+
+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 `