1230 lines
40 KiB
Markdown
1230 lines
40 KiB
Markdown
# Phase 5A — Watch-Together (YouTube)
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Per-conversation synchronized YouTube playback. Composer → "📺 Watch Together" → URL input → INSERT session row → inline bubble "Anna hat Watch-Together gestartet · [Beitreten]". Click Beitreten → fullscreen IFrame Player; the owner's play/pause/seek replicates to every joiner via realtime updates to `current_state`; joiners auto-reconcile when local drift > 2s.
|
|
|
|
**Architecture:**
|
|
- Server: one new table `public.conversation_watch_sessions (id, conversation_id, owner_user_id, video_id text, started_at, ended_at, current_state jsonb)`. RLS via existing `is_conversation_member(cid)`. Added to `supabase_realtime`.
|
|
- Bubble = a normal `messages` row whose plaintext payload is `{v:1, type:'watch_together', session_id}`. `MessageBubble` dispatches on `parsed.kind === 'watch_together'` to render the "Beitreten" tile.
|
|
- Modal lazy-loads `https://www.youtube.com/iframe_api` on first open, instantiates `window.YT.Player` and renders into a `<div ref>` slot. Owner taps controls → `useWatchSession.pushState({playing, positionSeconds})` updates `current_state` (throttled 500ms) and other joiners receive the change via realtime postgres_changes UPDATE → reconcile local player when delta > 2s.
|
|
- Owner leaves (closes modal) → `endSession()` sets `ended_at = now()`, the bubble shows "Beendet" and disables Beitreten. Non-owner leave keeps the session open.
|
|
|
|
**Tech Stack:** PostgreSQL + RLS + Supabase Realtime; React 18; YouTube IFrame Player API (loaded lazily — no NPM dep); no new build-time deps.
|
|
|
|
**Non-goals:**
|
|
- No 12h auto-cleanup cron (post-MVP, server-side).
|
|
- No Twitch / Vimeo / self-hosted (YouTube only per spec).
|
|
- No transfer-ownership UI (owner-only controls; if owner leaves the session is over).
|
|
- No chat overlay in the player.
|
|
|
|
---
|
|
|
|
## Pre-flight
|
|
|
|
- [ ] **Verify clean working tree on `main`**
|
|
|
|
Run: `cd "D:\Programmieren\ChatApp-Electron\chat-app" && git status`
|
|
Expected: clean.
|
|
|
|
- [ ] **Confirm tooling is green**
|
|
|
|
Run: `pnpm --filter @chat-app/shared typecheck && pnpm --filter @chat-app/desktop typecheck && pnpm --filter @chat-app/shared test -- --run`
|
|
Expected: all green; 42 shared tests pass.
|
|
|
|
---
|
|
|
|
## Task 1: SQL migration — `conversation_watch_sessions` + RLS + realtime publication
|
|
|
|
**Files:**
|
|
- Create: `supabase/migrations/20260516000008_watch_together.sql`
|
|
|
|
Idempotent.
|
|
|
|
- [ ] **Step 1: Write the SQL migration**
|
|
|
|
Create `supabase/migrations/20260516000008_watch_together.sql`:
|
|
|
|
```sql
|
|
-- Phase 5A: per-conversation synchronized YouTube playback.
|
|
--
|
|
-- conversation_watch_sessions: one row per Watch-Together session. The bubble
|
|
-- in the chat is a normal `messages` row whose plaintext payload is
|
|
-- `{v:1, type:'watch_together', session_id:<id>}`. The owner's player drives
|
|
-- current_state (jsonb {playing, position_seconds, updated_at_ms}); other
|
|
-- joiners reconcile via realtime postgres_changes UPDATE when local drift
|
|
-- exceeds 2 seconds.
|
|
|
|
create table if not exists public.conversation_watch_sessions (
|
|
id uuid primary key default gen_random_uuid(),
|
|
conversation_id uuid not null references public.conversations(id) on delete cascade,
|
|
owner_user_id uuid not null references auth.users(id) on delete cascade,
|
|
video_id text not null,
|
|
started_at timestamptz not null default now(),
|
|
ended_at timestamptz null,
|
|
current_state jsonb not null default '{"playing":false,"position_seconds":0,"updated_at_ms":0}'::jsonb,
|
|
constraint conversation_watch_sessions_video_id_len check (length(video_id) between 1 and 64)
|
|
);
|
|
|
|
create index if not exists conversation_watch_sessions_conv_idx
|
|
on public.conversation_watch_sessions(conversation_id, started_at desc);
|
|
|
|
alter table public.conversation_watch_sessions enable row level security;
|
|
|
|
drop policy if exists conversation_watch_sessions_select on public.conversation_watch_sessions;
|
|
drop policy if exists conversation_watch_sessions_insert on public.conversation_watch_sessions;
|
|
drop policy if exists conversation_watch_sessions_update on public.conversation_watch_sessions;
|
|
|
|
create policy conversation_watch_sessions_select
|
|
on public.conversation_watch_sessions
|
|
for select
|
|
using (public.is_conversation_member(conversation_id));
|
|
|
|
create policy conversation_watch_sessions_insert
|
|
on public.conversation_watch_sessions
|
|
for insert
|
|
with check (
|
|
public.is_conversation_member(conversation_id)
|
|
and owner_user_id = auth.uid()
|
|
);
|
|
|
|
create policy conversation_watch_sessions_update
|
|
on public.conversation_watch_sessions
|
|
for update
|
|
using (owner_user_id = auth.uid())
|
|
with check (owner_user_id = auth.uid());
|
|
|
|
alter table public.conversation_watch_sessions replica identity full;
|
|
|
|
do $$
|
|
begin
|
|
if not exists (
|
|
select 1
|
|
from pg_publication_tables
|
|
where pubname = 'supabase_realtime'
|
|
and schemaname = 'public'
|
|
and tablename = 'conversation_watch_sessions'
|
|
) then
|
|
execute 'alter publication supabase_realtime add table public.conversation_watch_sessions';
|
|
end if;
|
|
end
|
|
$$;
|
|
```
|
|
|
|
- [ ] **Step 2: Commit + push to prod**
|
|
|
|
```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<WatchTogetherPayload>;
|
|
const id = typeof p.session_id === 'string' && p.session_id.length > 0
|
|
? p.session_id
|
|
: '';
|
|
return { kind: 'watch_together', sessionId: id };
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Create the wrapper**
|
|
|
|
Create `packages/shared/src/chat/watchTogether.ts`:
|
|
|
|
```ts
|
|
import type { AppSupabaseClient } from '../supabase/client';
|
|
|
|
export interface WatchSessionState {
|
|
playing: boolean;
|
|
positionSeconds: number;
|
|
updatedAtMs: number;
|
|
}
|
|
|
|
export interface WatchSession {
|
|
id: string;
|
|
conversationId: string;
|
|
ownerUserId: string;
|
|
videoId: string;
|
|
startedAt: string;
|
|
endedAt: string | null;
|
|
currentState: WatchSessionState;
|
|
}
|
|
|
|
// YouTube URL → 11-char video id. Returns null if no match.
|
|
export function parseYouTubeUrl(input: string): string | null {
|
|
const s = input.trim();
|
|
if (!s) return null;
|
|
if (/^[A-Za-z0-9_-]{11}$/.test(s)) return s;
|
|
const patterns = [
|
|
/[?&]v=([A-Za-z0-9_-]{11})/,
|
|
/youtu\.be\/([A-Za-z0-9_-]{11})/,
|
|
/youtube\.com\/embed\/([A-Za-z0-9_-]{11})/,
|
|
/youtube\.com\/shorts\/([A-Za-z0-9_-]{11})/,
|
|
];
|
|
for (const re of patterns) {
|
|
const m = re.exec(s);
|
|
if (m && m[1]) return m[1];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export async function createWatchSession(
|
|
client: AppSupabaseClient,
|
|
params: { conversationId: string; videoId: string },
|
|
): Promise<WatchSession> {
|
|
const { data: session } = await client.auth.getUser();
|
|
if (!session.user) throw new Error('not authenticated');
|
|
const { data, error } = await client
|
|
.from('conversation_watch_sessions')
|
|
.insert({
|
|
conversation_id: params.conversationId,
|
|
owner_user_id: session.user.id,
|
|
video_id: params.videoId,
|
|
})
|
|
.select('id, conversation_id, owner_user_id, video_id, started_at, ended_at, current_state')
|
|
.single();
|
|
if (error) throw error;
|
|
return mapRow(data);
|
|
}
|
|
|
|
export async function getWatchSession(
|
|
client: AppSupabaseClient,
|
|
sessionId: string,
|
|
): Promise<WatchSession | null> {
|
|
const { data, error } = await client
|
|
.from('conversation_watch_sessions')
|
|
.select('id, conversation_id, owner_user_id, video_id, started_at, ended_at, current_state')
|
|
.eq('id', sessionId)
|
|
.maybeSingle();
|
|
if (error) throw error;
|
|
return data ? mapRow(data) : null;
|
|
}
|
|
|
|
export async function updateWatchSessionState(
|
|
client: AppSupabaseClient,
|
|
sessionId: string,
|
|
state: WatchSessionState,
|
|
): Promise<void> {
|
|
const { error } = await client
|
|
.from('conversation_watch_sessions')
|
|
.update({
|
|
current_state: {
|
|
playing: state.playing,
|
|
position_seconds: state.positionSeconds,
|
|
updated_at_ms: state.updatedAtMs,
|
|
},
|
|
})
|
|
.eq('id', sessionId);
|
|
if (error) throw error;
|
|
}
|
|
|
|
export async function endWatchSession(
|
|
client: AppSupabaseClient,
|
|
sessionId: string,
|
|
): Promise<void> {
|
|
const { error } = await client
|
|
.from('conversation_watch_sessions')
|
|
.update({ ended_at: new Date().toISOString() })
|
|
.eq('id', sessionId);
|
|
if (error) throw error;
|
|
}
|
|
|
|
function mapRow(row: {
|
|
id: string;
|
|
conversation_id: string;
|
|
owner_user_id: string;
|
|
video_id: string;
|
|
started_at: string;
|
|
ended_at: string | null;
|
|
current_state: unknown;
|
|
}): WatchSession {
|
|
const raw = (row.current_state ?? {}) as Partial<{
|
|
playing: boolean;
|
|
position_seconds: number;
|
|
updated_at_ms: number;
|
|
}>;
|
|
return {
|
|
id: row.id,
|
|
conversationId: row.conversation_id,
|
|
ownerUserId: row.owner_user_id,
|
|
videoId: row.video_id,
|
|
startedAt: row.started_at,
|
|
endedAt: row.ended_at,
|
|
currentState: {
|
|
playing: typeof raw.playing === 'boolean' ? raw.playing : false,
|
|
positionSeconds: typeof raw.position_seconds === 'number' ? raw.position_seconds : 0,
|
|
updatedAtMs: typeof raw.updated_at_ms === 'number' ? raw.updated_at_ms : 0,
|
|
},
|
|
};
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Tests**
|
|
|
|
Create `packages/shared/src/chat/watchTogether.test.ts`:
|
|
|
|
```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<void>;
|
|
} {
|
|
const [session, setSession] = useState<WatchSession | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const pendingRef = useRef<WatchSessionState | null>(null);
|
|
const lastPushAtRef = useRef<number>(0);
|
|
const pushTimerRef = useRef<number | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!sessionId) {
|
|
setSession(null);
|
|
setLoading(false);
|
|
setError(null);
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
void (async () => {
|
|
try {
|
|
setLoading(true);
|
|
setError(null);
|
|
const fresh = await getWatchSession(supabase, sessionId);
|
|
if (!cancelled) {
|
|
setSession(fresh);
|
|
setLoading(false);
|
|
}
|
|
} catch (err) {
|
|
if (!cancelled) {
|
|
setLoading(false);
|
|
setError(err instanceof Error ? err.message : 'failed to load session');
|
|
}
|
|
}
|
|
})();
|
|
|
|
const channel = supabase
|
|
.channel('watch_session:' + sessionId)
|
|
.on(
|
|
'postgres_changes',
|
|
{
|
|
event: 'UPDATE',
|
|
schema: 'public',
|
|
table: 'conversation_watch_sessions',
|
|
filter: 'id=eq.' + sessionId,
|
|
},
|
|
(payload) => {
|
|
const row = payload.new as {
|
|
id?: string;
|
|
ended_at?: string | null;
|
|
current_state?: unknown;
|
|
} | null;
|
|
if (!row?.id) return;
|
|
const raw = (row.current_state ?? {}) as Partial<{
|
|
playing: boolean;
|
|
position_seconds: number;
|
|
updated_at_ms: number;
|
|
}>;
|
|
setSession((cur) => {
|
|
if (!cur) return cur;
|
|
return {
|
|
...cur,
|
|
endedAt: row.ended_at ?? null,
|
|
currentState: {
|
|
playing: typeof raw.playing === 'boolean' ? raw.playing : cur.currentState.playing,
|
|
positionSeconds:
|
|
typeof raw.position_seconds === 'number'
|
|
? raw.position_seconds
|
|
: cur.currentState.positionSeconds,
|
|
updatedAtMs:
|
|
typeof raw.updated_at_ms === 'number'
|
|
? raw.updated_at_ms
|
|
: cur.currentState.updatedAtMs,
|
|
},
|
|
};
|
|
});
|
|
},
|
|
)
|
|
.subscribe();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
void supabase.removeChannel(channel);
|
|
if (pushTimerRef.current !== null) {
|
|
window.clearTimeout(pushTimerRef.current);
|
|
pushTimerRef.current = null;
|
|
}
|
|
};
|
|
}, [sessionId]);
|
|
|
|
const pushState = useCallback((state: WatchSessionState) => {
|
|
if (!sessionId) return;
|
|
pendingRef.current = state;
|
|
const now = Date.now();
|
|
const elapsed = now - lastPushAtRef.current;
|
|
if (elapsed >= PUSH_THROTTLE_MS) {
|
|
lastPushAtRef.current = now;
|
|
const toPush = pendingRef.current;
|
|
pendingRef.current = null;
|
|
void updateWatchSessionState(supabase, sessionId, toPush).catch((err) => {
|
|
console.warn('updateWatchSessionState failed', err);
|
|
});
|
|
return;
|
|
}
|
|
if (pushTimerRef.current !== null) window.clearTimeout(pushTimerRef.current);
|
|
pushTimerRef.current = window.setTimeout(() => {
|
|
pushTimerRef.current = null;
|
|
const toPush = pendingRef.current;
|
|
if (!toPush) return;
|
|
pendingRef.current = null;
|
|
lastPushAtRef.current = Date.now();
|
|
void updateWatchSessionState(supabase, sessionId, toPush).catch((err) => {
|
|
console.warn('updateWatchSessionState trailing failed', err);
|
|
});
|
|
}, PUSH_THROTTLE_MS - elapsed);
|
|
}, [sessionId]);
|
|
|
|
const endSession = useCallback(async () => {
|
|
if (!sessionId) return;
|
|
await endWatchSession(supabase, sessionId);
|
|
}, [sessionId]);
|
|
|
|
return { session, loading, error, pushState, endSession };
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Typecheck**
|
|
|
|
```
|
|
pnpm --filter @chat-app/desktop typecheck
|
|
```
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```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<YTNamespace> | null = null;
|
|
|
|
function loadIframeApi(): Promise<YTNamespace> {
|
|
if (apiPromise) return apiPromise;
|
|
apiPromise = new Promise((resolve, reject) => {
|
|
if (typeof window === 'undefined') {
|
|
reject(new Error('no window'));
|
|
return;
|
|
}
|
|
if (window.YT?.Player) {
|
|
resolve(window.YT);
|
|
return;
|
|
}
|
|
const prev = window.onYouTubeIframeAPIReady;
|
|
window.onYouTubeIframeAPIReady = () => {
|
|
try {
|
|
prev?.();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
if (window.YT?.Player) resolve(window.YT);
|
|
else reject(new Error('YT namespace missing after ready'));
|
|
};
|
|
const existing = document.querySelector(
|
|
'script[src="' + IFRAME_API_URL + '"]',
|
|
);
|
|
if (existing) return;
|
|
const tag = document.createElement('script');
|
|
tag.src = IFRAME_API_URL;
|
|
tag.async = true;
|
|
document.head.appendChild(tag);
|
|
});
|
|
return apiPromise;
|
|
}
|
|
|
|
interface Props {
|
|
sessionId: string;
|
|
onClose: () => void;
|
|
}
|
|
|
|
const DRIFT_THRESHOLD_SECONDS = 2;
|
|
|
|
export function WatchTogetherModal({ sessionId, onClose }: Props) {
|
|
const { t } = useTranslation();
|
|
const { session, pushState, endSession, error, loading } = useWatchSession(sessionId);
|
|
const { session: auth } = useAuth();
|
|
const mountRef = useRef<HTMLDivElement | null>(null);
|
|
const playerRef = useRef<YTPlayer | null>(null);
|
|
const [playerReady, setPlayerReady] = useState(false);
|
|
const ownerId = session?.ownerUserId ?? null;
|
|
const isOwner = !!auth?.user.id && auth.user.id === ownerId;
|
|
const ended = !!session?.endedAt;
|
|
|
|
useEffect(() => {
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') onClose();
|
|
};
|
|
window.addEventListener('keydown', onKey);
|
|
return () => window.removeEventListener('keydown', onKey);
|
|
}, [onClose]);
|
|
|
|
useEffect(() => {
|
|
if (!session?.videoId || !mountRef.current) return;
|
|
if (playerRef.current) return;
|
|
let disposed = false;
|
|
void loadIframeApi().then((YT) => {
|
|
if (disposed || !mountRef.current) return;
|
|
playerRef.current = new YT.Player(mountRef.current, {
|
|
width: '100%',
|
|
height: '100%',
|
|
videoId: session.videoId,
|
|
playerVars: { autoplay: 1, controls: isOwner ? 1 : 0, modestbranding: 1 },
|
|
events: {
|
|
onReady: () => setPlayerReady(true),
|
|
onStateChange: (ev) => {
|
|
if (!isOwner) return;
|
|
const playing = ev.data === YT.PlayerState.PLAYING;
|
|
const pos = ev.target.getCurrentTime();
|
|
pushState({ playing, positionSeconds: pos, updatedAtMs: Date.now() });
|
|
},
|
|
},
|
|
});
|
|
}).catch((err) => {
|
|
console.error('YouTube IFrame API failed', err);
|
|
});
|
|
return () => {
|
|
disposed = true;
|
|
try {
|
|
playerRef.current?.destroy();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
playerRef.current = null;
|
|
};
|
|
}, [session?.videoId, isOwner, pushState]);
|
|
|
|
// Owner-side heartbeat: every second while the modal is open, push the
|
|
// current player state so joiners always have a fresh reference point.
|
|
useEffect(() => {
|
|
if (!isOwner || !playerReady) return;
|
|
const id = window.setInterval(() => {
|
|
const p = playerRef.current;
|
|
if (!p) return;
|
|
try {
|
|
const state = p.getPlayerState();
|
|
const playing = state === window.YT?.PlayerState.PLAYING;
|
|
pushState({
|
|
playing,
|
|
positionSeconds: p.getCurrentTime(),
|
|
updatedAtMs: Date.now(),
|
|
});
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}, 1000);
|
|
return () => window.clearInterval(id);
|
|
}, [isOwner, playerReady, pushState]);
|
|
|
|
// Joiner-side reconcile: project the remote position forward by the time
|
|
// since it was sent; seek the local player if drift > threshold or if the
|
|
// playing flag mismatches.
|
|
useEffect(() => {
|
|
if (isOwner || !playerReady || !session) return;
|
|
const p = playerRef.current;
|
|
if (!p) return;
|
|
const remoteAgeSec = Math.max(0, (Date.now() - session.currentState.updatedAtMs) / 1000);
|
|
const projectedRemote = session.currentState.playing
|
|
? session.currentState.positionSeconds + remoteAgeSec
|
|
: session.currentState.positionSeconds;
|
|
let local = 0;
|
|
try {
|
|
local = p.getCurrentTime();
|
|
} catch {
|
|
return;
|
|
}
|
|
if (Math.abs(local - projectedRemote) > DRIFT_THRESHOLD_SECONDS) {
|
|
try {
|
|
p.seekTo(projectedRemote, true);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
try {
|
|
const state = p.getPlayerState();
|
|
const localPlaying = state === window.YT?.PlayerState.PLAYING;
|
|
if (session.currentState.playing && !localPlaying) {
|
|
p.playVideo();
|
|
} else if (!session.currentState.playing && localPlaying) {
|
|
p.pauseVideo();
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}, [isOwner, playerReady, session]);
|
|
|
|
const handleClose = async () => {
|
|
if (isOwner && !ended) {
|
|
try {
|
|
await endSession();
|
|
} catch (err) {
|
|
console.warn('endSession failed', err);
|
|
}
|
|
}
|
|
onClose();
|
|
};
|
|
|
|
return (
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label={t('app:watch.title', { defaultValue: 'Watch Together' })}
|
|
className="fixed inset-0 z-[80] flex flex-col bg-black"
|
|
>
|
|
<header className="flex shrink-0 items-center justify-between border-b border-line/40 bg-surface-2 px-4 py-2">
|
|
<h2 className="font-display text-sm font-semibold text-fg">
|
|
{t('app:watch.title', { defaultValue: 'Watch Together' })}
|
|
{ended && (
|
|
<span className="ml-2 rounded-md bg-rose-500/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-rose-600 dark:text-rose-300">
|
|
{t('app:watch.ended', { defaultValue: 'Beendet' })}
|
|
</span>
|
|
)}
|
|
</h2>
|
|
<button
|
|
type="button"
|
|
onClick={() => void handleClose()}
|
|
aria-label={t('app:watch.close', { defaultValue: 'Schließen' })}
|
|
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg"
|
|
>
|
|
<XIcon className="h-3.5 w-3.5" />
|
|
</button>
|
|
</header>
|
|
|
|
<div className="flex min-h-0 flex-1 items-center justify-center overflow-hidden bg-black p-4">
|
|
{loading ? (
|
|
<p className="text-sm text-fg-muted">
|
|
{t('app:watch.loading', { defaultValue: 'Lädt…' })}
|
|
</p>
|
|
) : error ? (
|
|
<p className="text-sm text-rose-400">{error}</p>
|
|
) : !session ? (
|
|
<p className="text-sm text-fg-muted">
|
|
{t('app:watch.missing', { defaultValue: 'Session nicht gefunden.' })}
|
|
</p>
|
|
) : (
|
|
<div className="aspect-video w-full max-w-5xl">
|
|
<div ref={mountRef} className="h-full w-full" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<footer className="flex shrink-0 items-center justify-between gap-3 border-t border-line/40 bg-surface-2 px-4 py-2 text-xs text-fg-muted">
|
|
<span>
|
|
{isOwner
|
|
? t('app:watch.you_are_host', { defaultValue: 'Du steuerst die Wiedergabe.' })
|
|
: t('app:watch.you_are_guest', { defaultValue: 'Nur der Host kann steuern.' })}
|
|
</span>
|
|
<span>
|
|
{session?.currentState.playing
|
|
? t('app:watch.playing', { defaultValue: '▶ Läuft' })
|
|
: t('app:watch.paused', { defaultValue: '⏸ Pause' })}
|
|
</span>
|
|
</footer>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
If the project's tsconfig rejects the inline `declare global`, move the `Window` augmentation to a `.d.ts` next to the file.
|
|
|
|
- [ ] **Step 2: Typecheck**
|
|
|
|
```
|
|
pnpm --filter @chat-app/desktop typecheck
|
|
```
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```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<string | null>(null);
|
|
const [watchDialogOpen, setWatchDialogOpen] = useState(false);
|
|
const [watchUrl, setWatchUrl] = useState('');
|
|
const [watchError, setWatchError] = useState<string | null>(null);
|
|
const [watchCreating, setWatchCreating] = useState(false);
|
|
```
|
|
|
|
**C. Handler** — near `handleCreateWhiteboard`:
|
|
```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
|
|
<button
|
|
type="button"
|
|
onClick={() => setWatchDialogOpen(true)}
|
|
title={t('app:composer.watch_together', { defaultValue: 'Watch Together' })}
|
|
aria-label={t('app:composer.watch_together', { defaultValue: 'Watch Together' })}
|
|
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-md text-fg-muted transition hover:bg-surface-3 hover:text-fg"
|
|
>
|
|
<PlayBoxIcon className="h-4 w-4" />
|
|
</button>
|
|
```
|
|
|
|
Inline icon at the bottom of the file:
|
|
```tsx
|
|
function PlayBoxIcon(props: React.SVGProps<SVGSVGElement>) {
|
|
return (
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}
|
|
strokeLinecap="round" strokeLinejoin="round" {...props}>
|
|
<rect x="3" y="4" width="18" height="14" rx="2" />
|
|
<path d="M10 9l5 3-5 3V9z" fill="currentColor" stroke="none" />
|
|
</svg>
|
|
);
|
|
}
|
|
```
|
|
|
|
**E. URL-input dialog** — render at the page root (alongside other dialogs):
|
|
```tsx
|
|
{watchDialogOpen && (
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
className="fixed inset-0 z-[70] flex items-center justify-center bg-ink-950/90 p-6"
|
|
onClick={(e) => {
|
|
if (e.target === e.currentTarget) setWatchDialogOpen(false);
|
|
}}
|
|
>
|
|
<div className="w-full max-w-md rounded-2xl border border-line bg-surface-2 p-5 shadow-xl">
|
|
<h2 className="mb-3 font-display text-lg font-semibold text-fg">
|
|
{t('app:watch.dialog_title', { defaultValue: 'Watch Together starten' })}
|
|
</h2>
|
|
<input
|
|
type="url"
|
|
placeholder="https://youtu.be/..."
|
|
value={watchUrl}
|
|
onChange={(e) => { setWatchUrl(e.target.value); setWatchError(null); }}
|
|
className="mb-2 w-full rounded-md border border-line bg-surface-3 px-3 py-2 text-sm text-fg placeholder-fg-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/40"
|
|
/>
|
|
{watchError && (
|
|
<p className="mb-2 text-xs text-rose-400">{watchError}</p>
|
|
)}
|
|
<div className="mt-3 flex items-center justify-end gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => { setWatchDialogOpen(false); setWatchUrl(''); setWatchError(null); }}
|
|
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-1.5 text-xs font-medium text-fg-muted transition hover:bg-surface"
|
|
>
|
|
{t('app:watch.cancel', { defaultValue: 'Abbrechen' })}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => void handleStartWatchTogether()}
|
|
disabled={watchCreating || !watchUrl.trim()}
|
|
className="cursor-pointer rounded-md bg-accent px-4 py-1.5 text-xs font-semibold text-accent-fg transition hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
{watchCreating
|
|
? t('app:watch.starting', { defaultValue: 'Startet…' })
|
|
: t('app:watch.start', { defaultValue: 'Starten' })}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
```
|
|
|
|
**F. Modal mount** — alongside the Whiteboard modal mount at the page root:
|
|
```tsx
|
|
{openWatchSessionId && (
|
|
<WatchTogetherModal
|
|
sessionId={openWatchSessionId}
|
|
onClose={() => 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 (
|
|
<div className="flex items-center gap-3 rounded-2xl border border-accent/30 bg-accent/5 px-4 py-3">
|
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-accent/20 text-accent">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}
|
|
strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
|
|
<rect x="3" y="4" width="18" height="14" rx="2" />
|
|
<path d="M10 9l5 3-5 3V9z" fill="currentColor" stroke="none" />
|
|
</svg>
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-sm font-semibold text-fg">Watch Together</div>
|
|
<div className="text-xs text-fg-muted">YouTube synchronisiert ansehen</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
window.dispatchEvent(
|
|
new CustomEvent('chatapp:open-watch-together', { detail: { id } }),
|
|
);
|
|
}}
|
|
disabled={!id}
|
|
className="cursor-pointer rounded-md bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
Beitreten
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Typecheck**
|
|
|
|
```
|
|
pnpm --filter @chat-app/desktop typecheck
|
|
```
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```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 `<script src=...>`. No build-time dep" → T4 `loadIframeApi()`
|
|
- "Auto-end after 12h inactivity (cron)" → out of scope per non-goals; spec calls it server-side
|
|
- "Owner-leave sets `ended_at`. Non-owner leave keeps session open" → T4 `handleClose` is owner-only `endSession`
|
|
|
|
2. **Placeholders:** none.
|
|
|
|
3. **Type consistency:**
|
|
- `WatchSession` (camelCase) ↔ `conversation_watch_sessions` (snake_case): isolated in `mapRow`.
|
|
- `WatchSessionState.positionSeconds`/`updatedAtMs` ↔ `position_seconds`/`updated_at_ms`: translated at the wrapper boundary.
|
|
- `WatchTogetherPayload.session_id` ↔ `ParsedMessagePayload.sessionId`: matches `WhiteboardPayload.whiteboard_id` → `whiteboardId` pattern.
|
|
|
|
4. **Realtime quirk:** owner's own pushes echo back via realtime. Owner-side reconcile effect is gated `if (isOwner) return;` so only joiners apply reconcile.
|
|
|
|
5. **Push throttle vs heartbeat:** owner heartbeat 1Hz; `pushState` throttles to 2Hz max. Bandwidth ~2-4 KB/s during playback.
|