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'; 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]); 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]); 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.' })}

) : (
)}
); }