From 253a249fc7ef600d8900a9ba7f9c160925e4cc95 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 7 Mar 2026 23:49:23 +0100 Subject: [PATCH] =?UTF-8?q?Watch=20Together:=20alle=20User=20haben=20Strea?= =?UTF-8?q?m-Kontrolle,=20Dailymotion-Support,=20YouTube-Qualit=C3=A4tswah?= =?UTF-8?q?l?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Host-Only-Beschränkung für play/pause/resume/seek/skip entfernt — alle Raum-Mitglieder können jetzt die Wiedergabe steuern - Dailymotion-Videos können jetzt abgespielt werden (postMessage API, iframe-basiert) - YouTube-Videoqualität einstellbar (Standard: 1080p, wird in localStorage gespeichert) - Queue-Items sind für alle User klickbar - "Gesehene entfernen"-Button für alle sichtbar Co-Authored-By: Claude Opus 4.6 --- server/src/plugins/watch-together/index.ts | 20 -- .../watch-together/WatchTogetherTab.tsx | 175 +++++++++++++----- .../plugins/watch-together/watch-together.css | 23 +++ 3 files changed, 154 insertions(+), 64 deletions(-) diff --git a/server/src/plugins/watch-together/index.ts b/server/src/plugins/watch-together/index.ts index 52c413f..66363ea 100644 --- a/server/src/plugins/watch-together/index.ts +++ b/server/src/plugins/watch-together/index.ts @@ -364,10 +364,6 @@ async function handleMessage(client: WtClient, msg: any): Promise { if (!client.roomId) return; const room = rooms.get(client.roomId); if (!room) return; - if (room.hostId !== client.id) { - sendTo(client, { type: 'error', code: 'NOT_HOST', message: 'Nur der Host kann die Wiedergabe steuern.' }); - return; - } const index = msg.index != null ? Number(msg.index) : undefined; if (index !== undefined) { @@ -403,10 +399,6 @@ async function handleMessage(client: WtClient, msg: any): Promise { if (!client.roomId) return; const room = rooms.get(client.roomId); if (!room) return; - if (room.hostId !== client.id) { - sendTo(client, { type: 'error', code: 'NOT_HOST', message: 'Nur der Host kann die Wiedergabe steuern.' }); - return; - } // Calculate drift before pausing room.currentTime = room.currentTime + (room.playing ? (Date.now() - room.lastSyncAt) / 1000 : 0); room.playing = false; @@ -420,10 +412,6 @@ async function handleMessage(client: WtClient, msg: any): Promise { if (!client.roomId) return; const room = rooms.get(client.roomId); if (!room) return; - if (room.hostId !== client.id) { - sendTo(client, { type: 'error', code: 'NOT_HOST', message: 'Nur der Host kann die Wiedergabe steuern.' }); - return; - } room.playing = true; room.lastSyncAt = Date.now(); sendToRoom(room.id, getPlaybackState(room)); @@ -435,10 +423,6 @@ async function handleMessage(client: WtClient, msg: any): Promise { if (!client.roomId) return; const room = rooms.get(client.roomId); if (!room) return; - if (room.hostId !== client.id) { - sendTo(client, { type: 'error', code: 'NOT_HOST', message: 'Nur der Host kann die Wiedergabe steuern.' }); - return; - } room.currentTime = Number(msg.time) || 0; room.lastSyncAt = Date.now(); sendToRoom(room.id, getPlaybackState(room)); @@ -449,10 +433,6 @@ async function handleMessage(client: WtClient, msg: any): Promise { if (!client.roomId) return; const room = rooms.get(client.roomId); if (!room) return; - if (room.hostId !== client.id) { - sendTo(client, { type: 'error', code: 'NOT_HOST', message: 'Nur der Host kann die Wiedergabe steuern.' }); - return; - } // Mark current video as watched in queue if (room.currentVideo) { const currentItem = room.queue.find(q => q.url === room.currentVideo!.url); diff --git a/web/src/plugins/watch-together/WatchTogetherTab.tsx b/web/src/plugins/watch-together/WatchTogetherTab.tsx index 7a3053f..317a53f 100644 --- a/web/src/plugins/watch-together/WatchTogetherTab.tsx +++ b/web/src/plugins/watch-together/WatchTogetherTab.tsx @@ -42,9 +42,11 @@ function formatTime(s: number): string { return `${m}:${String(ss).padStart(2, '0')}`; } -function parseVideoUrl(url: string): { type: 'youtube'; videoId: string } | { type: 'direct'; url: string } | null { +function parseVideoUrl(url: string): { type: 'youtube'; videoId: string } | { type: 'dailymotion'; videoId: string } | { type: 'direct'; url: string } | null { const ytMatch = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/); if (ytMatch) return { type: 'youtube', videoId: ytMatch[1] }; + const dmMatch = url.match(/(?:dailymotion\.com\/video\/|dai\.ly\/)([a-zA-Z0-9]+)/); + if (dmMatch) return { type: 'dailymotion', videoId: dmMatch[1] }; if (/\.(mp4|webm|ogg)(\?|$)/i.test(url) || url.startsWith('http')) return { type: 'direct', url }; return null; } @@ -82,6 +84,7 @@ export default function WatchTogetherTab({ data }: { data: any }) { const [votes, setVotes] = useState<{ skip: number; pause: number; total: number; needed: number } | null>(null); const [syncStatus, setSyncStatus] = useState<'synced' | 'drifting' | 'desynced'>('synced'); const [showChat, setShowChat] = useState(true); + const [ytQuality, setYtQuality] = useState(() => localStorage.getItem('wt_yt_quality') || 'hd1080'); // ── Refs ── const wsRef = useRef(null); @@ -95,17 +98,35 @@ export default function WatchTogetherTab({ data }: { data: any }) { const ytPlayerRef = useRef(null); const videoRef = useRef(null); const playerContainerRef = useRef(null); - const currentVideoTypeRef = useRef<'youtube' | 'direct' | null>(null); + const currentVideoTypeRef = useRef<'youtube' | 'direct' | 'dailymotion' | null>(null); const ytReadyRef = useRef(false); const seekingRef = useRef(false); const timeUpdateRef = useRef | null>(null); const chatEndRef = useRef(null); + // Quality ref (for YT player callbacks) + const ytQualityRef = useRef(ytQuality); + + // Dailymotion refs + const dmPlayerRef = useRef(null); + const dmReadyRef = useRef(false); + const dmTimeRef = useRef(0); + const dmDurationRef = useRef(0); + // Mirror state to refs useEffect(() => { currentRoomRef.current = currentRoom; }, [currentRoom]); const isHost = currentRoom != null && clientIdRef.current === currentRoom.hostId; + // ── Save quality + apply to running player ── + useEffect(() => { + ytQualityRef.current = ytQuality; + localStorage.setItem('wt_yt_quality', ytQuality); + if (ytPlayerRef.current && typeof ytPlayerRef.current.setPlaybackQuality === 'function') { + ytPlayerRef.current.setPlaybackQuality(ytQuality); + } + }, [ytQuality]); + // ── SSE data ── useEffect(() => { if (data?.rooms) { @@ -143,6 +164,9 @@ export default function WatchTogetherTab({ data }: { data: any }) { if (videoRef.current) { videoRef.current.volume = volume; } + if (dmPlayerRef.current?.contentWindow && currentVideoTypeRef.current === 'dailymotion') { + dmPlayerRef.current.contentWindow.postMessage(`volume?volume=${volume}`, 'https://www.dailymotion.com'); + } }, [volume]); // ── YouTube IFrame API ── @@ -173,6 +197,9 @@ export default function WatchTogetherTab({ data }: { data: any }) { if (currentVideoTypeRef.current === 'direct' && videoRef.current) { return videoRef.current.currentTime; } + if (currentVideoTypeRef.current === 'dailymotion') { + return dmTimeRef.current; + } return null; }, []); @@ -184,6 +211,9 @@ export default function WatchTogetherTab({ data }: { data: any }) { if (currentVideoTypeRef.current === 'direct' && videoRef.current) { return videoRef.current.duration || 0; } + if (currentVideoTypeRef.current === 'dailymotion') { + return dmDurationRef.current; + } return 0; }, []); @@ -198,6 +228,12 @@ export default function WatchTogetherTab({ data }: { data: any }) { videoRef.current.removeAttribute('src'); videoRef.current.load(); } + if (dmPlayerRef.current) { + dmPlayerRef.current.src = ''; + dmReadyRef.current = false; + dmTimeRef.current = 0; + dmDurationRef.current = 0; + } currentVideoTypeRef.current = null; if (timeUpdateRef.current) { clearInterval(timeUpdateRef.current); @@ -229,6 +265,7 @@ export default function WatchTogetherTab({ data }: { data: any }) { events: { onReady: (ev: any) => { ev.target.setVolume(volume * 100); + ev.target.setPlaybackQuality(ytQualityRef.current); setDuration(ev.target.getDuration() || 0); // Time update interval timeUpdateRef.current = setInterval(() => { @@ -268,6 +305,11 @@ export default function WatchTogetherTab({ data }: { data: any }) { }, }, }); + } else if (parsed.type === 'dailymotion') { + currentVideoTypeRef.current = 'dailymotion'; + if (dmPlayerRef.current) { + dmPlayerRef.current.src = `https://www.dailymotion.com/embed/video/${parsed.videoId}?api=postMessage&autoplay=1&controls=0&mute=0&queue-enable=0`; + } } else { currentVideoTypeRef.current = 'direct'; if (videoRef.current) { @@ -300,6 +342,39 @@ export default function WatchTogetherTab({ data }: { data: any }) { }; }, [wsSend]); + // ── Dailymotion postMessage listener ── + useEffect(() => { + const handler = (ev: MessageEvent) => { + if (ev.origin !== 'https://www.dailymotion.com') return; + if (typeof ev.data !== 'string') return; + const params = new URLSearchParams(ev.data); + const method = params.get('method'); + const value = params.get('value'); + + if (method === 'timeupdate' && value) { + const t = parseFloat(value); + dmTimeRef.current = t; + setCurrentTime(t); + } else if (method === 'durationchange' && value) { + const d = parseFloat(value); + dmDurationRef.current = d; + setDuration(d); + } else if (method === 'ended') { + const room = currentRoomRef.current; + if (room && clientIdRef.current === room.hostId) { + wsSend({ type: 'skip' }); + } + } else if (method === 'apiready') { + dmReadyRef.current = true; + if (dmPlayerRef.current?.contentWindow) { + dmPlayerRef.current.contentWindow.postMessage(`volume?volume=${volume}`, 'https://www.dailymotion.com'); + } + } + }; + window.addEventListener('message', handler); + return () => window.removeEventListener('message', handler); + }, [wsSend, volume]); + // ── WS message handler ── const handleWsMessageRef = useRef<(msg: any) => void>(() => {}); handleWsMessageRef.current = (msg: any) => { @@ -370,6 +445,12 @@ export default function WatchTogetherTab({ data }: { data: any }) { } else if (!msg.playing && !videoRef.current.paused) { videoRef.current.pause(); } + } else if (currentVideoTypeRef.current === 'dailymotion' && dmPlayerRef.current?.contentWindow) { + if (msg.playing) { + dmPlayerRef.current.contentWindow.postMessage('play', 'https://www.dailymotion.com'); + } else { + dmPlayerRef.current.contentWindow.postMessage('pause', 'https://www.dailymotion.com'); + } } // 3. Drift correction: if |localTime - serverTime| > 2 → seek @@ -380,6 +461,8 @@ export default function WatchTogetherTab({ data }: { data: any }) { ytPlayerRef.current.seekTo?.(msg.currentTime, true); } else if (currentVideoTypeRef.current === 'direct' && videoRef.current) { videoRef.current.currentTime = msg.currentTime; + } else if (currentVideoTypeRef.current === 'dailymotion' && dmPlayerRef.current?.contentWindow) { + dmPlayerRef.current.contentWindow.postMessage(`seek?to=${msg.currentTime}`, 'https://www.dailymotion.com'); } } } @@ -603,6 +686,8 @@ export default function WatchTogetherTab({ data }: { data: any }) { ytPlayerRef.current.seekTo?.(time, true); } else if (currentVideoTypeRef.current === 'direct' && videoRef.current) { videoRef.current.currentTime = time; + } else if (currentVideoTypeRef.current === 'dailymotion' && dmPlayerRef.current?.contentWindow) { + dmPlayerRef.current.contentWindow.postMessage(`seek?to=${time}`, 'https://www.dailymotion.com'); } setCurrentTime(time); setTimeout(() => { seekingRef.current = false; }, 1000); @@ -712,6 +797,13 @@ export default function WatchTogetherTab({ data }: { data: any }) { style={currentVideoTypeRef.current === 'direct' ? {} : { display: 'none' }} playsInline /> +