Watch Together: alle User haben Stream-Kontrolle, Dailymotion-Support, YouTube-Qualitätswahl

- 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 <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-03-07 23:49:23 +01:00
parent 7abd5551d0
commit 253a249fc7
3 changed files with 154 additions and 64 deletions

View file

@ -364,10 +364,6 @@ async function handleMessage(client: WtClient, msg: any): Promise<void> {
if (!client.roomId) return; if (!client.roomId) return;
const room = rooms.get(client.roomId); const room = rooms.get(client.roomId);
if (!room) return; 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; const index = msg.index != null ? Number(msg.index) : undefined;
if (index !== undefined) { if (index !== undefined) {
@ -403,10 +399,6 @@ async function handleMessage(client: WtClient, msg: any): Promise<void> {
if (!client.roomId) return; if (!client.roomId) return;
const room = rooms.get(client.roomId); const room = rooms.get(client.roomId);
if (!room) return; 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 // Calculate drift before pausing
room.currentTime = room.currentTime + (room.playing ? (Date.now() - room.lastSyncAt) / 1000 : 0); room.currentTime = room.currentTime + (room.playing ? (Date.now() - room.lastSyncAt) / 1000 : 0);
room.playing = false; room.playing = false;
@ -420,10 +412,6 @@ async function handleMessage(client: WtClient, msg: any): Promise<void> {
if (!client.roomId) return; if (!client.roomId) return;
const room = rooms.get(client.roomId); const room = rooms.get(client.roomId);
if (!room) return; 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.playing = true;
room.lastSyncAt = Date.now(); room.lastSyncAt = Date.now();
sendToRoom(room.id, getPlaybackState(room)); sendToRoom(room.id, getPlaybackState(room));
@ -435,10 +423,6 @@ async function handleMessage(client: WtClient, msg: any): Promise<void> {
if (!client.roomId) return; if (!client.roomId) return;
const room = rooms.get(client.roomId); const room = rooms.get(client.roomId);
if (!room) return; 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.currentTime = Number(msg.time) || 0;
room.lastSyncAt = Date.now(); room.lastSyncAt = Date.now();
sendToRoom(room.id, getPlaybackState(room)); sendToRoom(room.id, getPlaybackState(room));
@ -449,10 +433,6 @@ async function handleMessage(client: WtClient, msg: any): Promise<void> {
if (!client.roomId) return; if (!client.roomId) return;
const room = rooms.get(client.roomId); const room = rooms.get(client.roomId);
if (!room) return; 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 // Mark current video as watched in queue
if (room.currentVideo) { if (room.currentVideo) {
const currentItem = room.queue.find(q => q.url === room.currentVideo!.url); const currentItem = room.queue.find(q => q.url === room.currentVideo!.url);

View file

@ -42,9 +42,11 @@ function formatTime(s: number): string {
return `${m}:${String(ss).padStart(2, '0')}`; 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})/); 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] }; 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 }; if (/\.(mp4|webm|ogg)(\?|$)/i.test(url) || url.startsWith('http')) return { type: 'direct', url };
return null; 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 [votes, setVotes] = useState<{ skip: number; pause: number; total: number; needed: number } | null>(null);
const [syncStatus, setSyncStatus] = useState<'synced' | 'drifting' | 'desynced'>('synced'); const [syncStatus, setSyncStatus] = useState<'synced' | 'drifting' | 'desynced'>('synced');
const [showChat, setShowChat] = useState(true); const [showChat, setShowChat] = useState(true);
const [ytQuality, setYtQuality] = useState<string>(() => localStorage.getItem('wt_yt_quality') || 'hd1080');
// ── Refs ── // ── Refs ──
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
@ -95,17 +98,35 @@ export default function WatchTogetherTab({ data }: { data: any }) {
const ytPlayerRef = useRef<any>(null); const ytPlayerRef = useRef<any>(null);
const videoRef = useRef<HTMLVideoElement | null>(null); const videoRef = useRef<HTMLVideoElement | null>(null);
const playerContainerRef = useRef<HTMLDivElement | null>(null); const playerContainerRef = useRef<HTMLDivElement | null>(null);
const currentVideoTypeRef = useRef<'youtube' | 'direct' | null>(null); const currentVideoTypeRef = useRef<'youtube' | 'direct' | 'dailymotion' | null>(null);
const ytReadyRef = useRef(false); const ytReadyRef = useRef(false);
const seekingRef = useRef(false); const seekingRef = useRef(false);
const timeUpdateRef = useRef<ReturnType<typeof setInterval> | null>(null); const timeUpdateRef = useRef<ReturnType<typeof setInterval> | null>(null);
const chatEndRef = useRef<HTMLDivElement | null>(null); const chatEndRef = useRef<HTMLDivElement | null>(null);
// Quality ref (for YT player callbacks)
const ytQualityRef = useRef(ytQuality);
// Dailymotion refs
const dmPlayerRef = useRef<HTMLIFrameElement | null>(null);
const dmReadyRef = useRef(false);
const dmTimeRef = useRef(0);
const dmDurationRef = useRef(0);
// Mirror state to refs // Mirror state to refs
useEffect(() => { currentRoomRef.current = currentRoom; }, [currentRoom]); useEffect(() => { currentRoomRef.current = currentRoom; }, [currentRoom]);
const isHost = currentRoom != null && clientIdRef.current === currentRoom.hostId; 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 ── // ── SSE data ──
useEffect(() => { useEffect(() => {
if (data?.rooms) { if (data?.rooms) {
@ -143,6 +164,9 @@ export default function WatchTogetherTab({ data }: { data: any }) {
if (videoRef.current) { if (videoRef.current) {
videoRef.current.volume = volume; videoRef.current.volume = volume;
} }
if (dmPlayerRef.current?.contentWindow && currentVideoTypeRef.current === 'dailymotion') {
dmPlayerRef.current.contentWindow.postMessage(`volume?volume=${volume}`, 'https://www.dailymotion.com');
}
}, [volume]); }, [volume]);
// ── YouTube IFrame API ── // ── YouTube IFrame API ──
@ -173,6 +197,9 @@ export default function WatchTogetherTab({ data }: { data: any }) {
if (currentVideoTypeRef.current === 'direct' && videoRef.current) { if (currentVideoTypeRef.current === 'direct' && videoRef.current) {
return videoRef.current.currentTime; return videoRef.current.currentTime;
} }
if (currentVideoTypeRef.current === 'dailymotion') {
return dmTimeRef.current;
}
return null; return null;
}, []); }, []);
@ -184,6 +211,9 @@ export default function WatchTogetherTab({ data }: { data: any }) {
if (currentVideoTypeRef.current === 'direct' && videoRef.current) { if (currentVideoTypeRef.current === 'direct' && videoRef.current) {
return videoRef.current.duration || 0; return videoRef.current.duration || 0;
} }
if (currentVideoTypeRef.current === 'dailymotion') {
return dmDurationRef.current;
}
return 0; return 0;
}, []); }, []);
@ -198,6 +228,12 @@ export default function WatchTogetherTab({ data }: { data: any }) {
videoRef.current.removeAttribute('src'); videoRef.current.removeAttribute('src');
videoRef.current.load(); videoRef.current.load();
} }
if (dmPlayerRef.current) {
dmPlayerRef.current.src = '';
dmReadyRef.current = false;
dmTimeRef.current = 0;
dmDurationRef.current = 0;
}
currentVideoTypeRef.current = null; currentVideoTypeRef.current = null;
if (timeUpdateRef.current) { if (timeUpdateRef.current) {
clearInterval(timeUpdateRef.current); clearInterval(timeUpdateRef.current);
@ -229,6 +265,7 @@ export default function WatchTogetherTab({ data }: { data: any }) {
events: { events: {
onReady: (ev: any) => { onReady: (ev: any) => {
ev.target.setVolume(volume * 100); ev.target.setVolume(volume * 100);
ev.target.setPlaybackQuality(ytQualityRef.current);
setDuration(ev.target.getDuration() || 0); setDuration(ev.target.getDuration() || 0);
// Time update interval // Time update interval
timeUpdateRef.current = setInterval(() => { 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 { } else {
currentVideoTypeRef.current = 'direct'; currentVideoTypeRef.current = 'direct';
if (videoRef.current) { if (videoRef.current) {
@ -300,6 +342,39 @@ export default function WatchTogetherTab({ data }: { data: any }) {
}; };
}, [wsSend]); }, [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 ── // ── WS message handler ──
const handleWsMessageRef = useRef<(msg: any) => void>(() => {}); const handleWsMessageRef = useRef<(msg: any) => void>(() => {});
handleWsMessageRef.current = (msg: any) => { handleWsMessageRef.current = (msg: any) => {
@ -370,6 +445,12 @@ export default function WatchTogetherTab({ data }: { data: any }) {
} else if (!msg.playing && !videoRef.current.paused) { } else if (!msg.playing && !videoRef.current.paused) {
videoRef.current.pause(); 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 // 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); ytPlayerRef.current.seekTo?.(msg.currentTime, true);
} else if (currentVideoTypeRef.current === 'direct' && videoRef.current) { } else if (currentVideoTypeRef.current === 'direct' && videoRef.current) {
videoRef.current.currentTime = msg.currentTime; 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); ytPlayerRef.current.seekTo?.(time, true);
} else if (currentVideoTypeRef.current === 'direct' && videoRef.current) { } else if (currentVideoTypeRef.current === 'direct' && videoRef.current) {
videoRef.current.currentTime = time; 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); setCurrentTime(time);
setTimeout(() => { seekingRef.current = false; }, 1000); setTimeout(() => { seekingRef.current = false; }, 1000);
@ -712,6 +797,13 @@ export default function WatchTogetherTab({ data }: { data: any }) {
style={currentVideoTypeRef.current === 'direct' ? {} : { display: 'none' }} style={currentVideoTypeRef.current === 'direct' ? {} : { display: 'none' }}
playsInline playsInline
/> />
<iframe
ref={dmPlayerRef}
className="wt-dm-container"
style={currentVideoTypeRef.current === 'dailymotion' ? {} : { display: 'none' }}
allow="autoplay; fullscreen"
allowFullScreen
/>
{playerError && ( {playerError && (
<div className="wt-player-error"> <div className="wt-player-error">
<div className="wt-error-icon">{'\u26A0\uFE0F'}</div> <div className="wt-error-icon">{'\u26A0\uFE0F'}</div>
@ -738,44 +830,22 @@ export default function WatchTogetherTab({ data }: { data: any }) {
</div> </div>
<div className="wt-controls"> <div className="wt-controls">
{isHost ? ( <button className="wt-ctrl-btn" onClick={togglePlay} disabled={!currentRoom.currentVideo} title={currentRoom.playing ? 'Pause' : 'Abspielen'}>
<> {currentRoom.playing ? '\u23F8' : '\u25B6'}
<button className="wt-ctrl-btn" onClick={togglePlay} disabled={!currentRoom.currentVideo} title={currentRoom.playing ? 'Pause' : 'Abspielen'}> </button>
{currentRoom.playing ? '\u23F8' : '\u25B6'} <button className="wt-ctrl-btn wt-next-btn" onClick={skip} disabled={!currentRoom.currentVideo && currentRoom.queue.length === 0} title="Nächstes Video">
</button> {'\u23ED'} Weiter
<button className="wt-ctrl-btn wt-next-btn" onClick={skip} disabled={!currentRoom.currentVideo && currentRoom.queue.length === 0} title="Nächstes Video"> </button>
{'\u23ED'} Weiter <input
</button> className="wt-seek"
<input type="range"
className="wt-seek" min={0}
type="range" max={duration || 0}
min={0} step={0.5}
max={duration || 0} value={currentTime}
step={0.5} onChange={e => seek(parseFloat(e.target.value))}
value={currentTime} disabled={!currentRoom.currentVideo}
onChange={e => seek(parseFloat(e.target.value))} />
disabled={!currentRoom.currentVideo}
/>
</>
) : (
<>
<button className="wt-ctrl-btn wt-vote-btn" onClick={votePause} title="Pause abstimmen">
{currentRoom.playing ? '\u23F8' : '\u25B6'}
</button>
<button className="wt-ctrl-btn wt-vote-btn" onClick={voteSkip} title="Skip abstimmen">
{'\u23ED'}
</button>
{votes && votes.skip > 0 && (
<span className="wt-vote-count">{votes.skip}/{votes.needed} Skip</span>
)}
{votes && votes.pause > 0 && (
<span className="wt-vote-count">{votes.pause}/{votes.needed} Pause</span>
)}
<div className="wt-seek-readonly">
<div className="wt-seek-progress" style={{ width: duration > 0 ? `${(currentTime / duration) * 100}%` : '0%' }} />
</div>
</>
)}
<span className="wt-time">{formatTime(currentTime)} / {formatTime(duration)}</span> <span className="wt-time">{formatTime(currentTime)} / {formatTime(duration)}</span>
<div className="wt-volume"> <div className="wt-volume">
<span className="wt-volume-icon">{volume === 0 ? '\uD83D\uDD07' : volume < 0.5 ? '\uD83D\uDD09' : '\uD83D\uDD0A'}</span> <span className="wt-volume-icon">{volume === 0 ? '\uD83D\uDD07' : volume < 0.5 ? '\uD83D\uDD09' : '\uD83D\uDD0A'}</span>
@ -789,13 +859,30 @@ export default function WatchTogetherTab({ data }: { data: any }) {
onChange={e => setVolume(parseFloat(e.target.value))} onChange={e => setVolume(parseFloat(e.target.value))}
/> />
</div> </div>
{currentVideoTypeRef.current === 'youtube' && (
<select
className="wt-quality-select"
value={ytQuality}
onChange={e => setYtQuality(e.target.value)}
title="Videoqualit&auml;t"
>
<option value="highres">4K+</option>
<option value="hd2160">2160p</option>
<option value="hd1440">1440p</option>
<option value="hd1080">1080p</option>
<option value="hd720">720p</option>
<option value="large">480p</option>
<option value="medium">360p</option>
<option value="small">240p</option>
</select>
)}
</div> </div>
</div> </div>
<div className="wt-queue-panel"> <div className="wt-queue-panel">
<div className="wt-queue-header"> <div className="wt-queue-header">
<span>Warteschlange ({currentRoom.queue.length})</span> <span>Warteschlange ({currentRoom.queue.length})</span>
{isHost && currentRoom.queue.some(q => q.watched) && ( {currentRoom.queue.some(q => q.watched) && (
<button className="wt-queue-clear-btn" onClick={clearWatched} title="Gesehene entfernen">Gesehene entfernen</button> <button className="wt-queue-clear-btn" onClick={clearWatched} title="Gesehene entfernen">Gesehene entfernen</button>
)} )}
</div> </div>
@ -808,9 +895,9 @@ export default function WatchTogetherTab({ data }: { data: any }) {
return ( return (
<div <div
key={i} key={i}
className={`wt-queue-item${isCurrent ? ' playing' : ''}${item.watched && !isCurrent ? ' watched' : ''}${isHost ? ' clickable' : ''}`} className={`wt-queue-item${isCurrent ? ' playing' : ''}${item.watched && !isCurrent ? ' watched' : ''} clickable`}
onClick={() => isHost && wsSend({ type: 'play_video', index: i })} onClick={() => wsSend({ type: 'play_video', index: i })}
title={isHost ? 'Klicken zum Abspielen' : undefined} title="Klicken zum Abspielen"
> >
<div className="wt-queue-item-info"> <div className="wt-queue-item-info">
{item.watched && !isCurrent && <span className="wt-queue-item-check">{'\u2713'}</span>} {item.watched && !isCurrent && <span className="wt-queue-item-check">{'\u2713'}</span>}

View file

@ -377,6 +377,14 @@
height: 100%; height: 100%;
object-fit: contain; object-fit: contain;
} }
.wt-dm-container {
width: 100%;
height: 100%;
border: none;
position: absolute;
top: 0;
left: 0;
}
.wt-player-placeholder { .wt-player-placeholder {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -545,6 +553,21 @@
box-shadow: 0 0 4px rgba(0, 0, 0, 0.3); box-shadow: 0 0 4px rgba(0, 0, 0, 0.3);
} }
.wt-quality-select {
background: var(--bg-secondary, #2a2a3e);
color: var(--text-primary, #e0e0e0);
border: 1px solid var(--border-color, #3a3a4e);
border-radius: 6px;
padding: 2px 6px;
font-size: 12px;
cursor: pointer;
outline: none;
margin-left: 4px;
}
.wt-quality-select:hover {
border-color: var(--accent, #7c5cff);
}
/* /*
QUEUE PANEL QUEUE PANEL
*/ */