From 7d5f3b37cd82f8a45b6a9f55554c0ca4504d3d76 Mon Sep 17 00:00:00 2001 From: byGalax Date: Sat, 16 May 2026 21:26:59 +0200 Subject: [PATCH] =?UTF-8?q?feat(P5A.T4):=20WatchTogetherModal=20=E2=80=94?= =?UTF-8?q?=20YouTube=20IFrame=20+=20drift=20reconcile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/WatchTogetherModal.tsx | 260 ++++++++++++++++++ 1 file changed, 260 insertions(+) create mode 100644 apps/desktop/src/components/WatchTogetherModal.tsx diff --git a/apps/desktop/src/components/WatchTogetherModal.tsx b/apps/desktop/src/components/WatchTogetherModal.tsx new file mode 100644 index 0000000..38e6dfb --- /dev/null +++ b/apps/desktop/src/components/WatchTogetherModal.tsx @@ -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 | 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.' })} +

+ ) : ( +
+
+
+ )} +
+ + +
+ ); +}