From d1193142ae1a4c998d4b81df62f2ac0187219472 Mon Sep 17 00:00:00 2001 From: byGalax Date: Sat, 16 May 2026 21:14:38 +0200 Subject: [PATCH] docs(P5A): Phase 5A implementation plan (Watch-Together / YouTube) --- .../2026-05-16-phase5a-watch-together.md | 1229 +++++++++++++++++ 1 file changed, 1229 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-16-phase5a-watch-together.md 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.' })} +

+ ) : ( +
+
+
+ )} +
+ +
+ + {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 `