feat(P5A.T4): WatchTogetherModal — YouTube IFrame + drift reconcile
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
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<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]);
|
||||
|
||||
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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user