# 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 (