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;
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<void> {
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<void> {
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<void> {
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<void> {
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);

View file

@ -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<string>(() => localStorage.getItem('wt_yt_quality') || 'hd1080');
// ── Refs ──
const wsRef = useRef<WebSocket | null>(null);
@ -95,17 +98,35 @@ export default function WatchTogetherTab({ data }: { data: any }) {
const ytPlayerRef = useRef<any>(null);
const videoRef = useRef<HTMLVideoElement | 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 seekingRef = useRef(false);
const timeUpdateRef = useRef<ReturnType<typeof setInterval> | 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
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
/>
<iframe
ref={dmPlayerRef}
className="wt-dm-container"
style={currentVideoTypeRef.current === 'dailymotion' ? {} : { display: 'none' }}
allow="autoplay; fullscreen"
allowFullScreen
/>
{playerError && (
<div className="wt-player-error">
<div className="wt-error-icon">{'\u26A0\uFE0F'}</div>
@ -738,8 +830,6 @@ export default function WatchTogetherTab({ data }: { data: any }) {
</div>
<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>
@ -756,26 +846,6 @@ export default function WatchTogetherTab({ data }: { data: any }) {
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>
<div className="wt-volume">
<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))}
/>
</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 className="wt-queue-panel">
<div className="wt-queue-header">
<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>
)}
</div>
@ -808,9 +895,9 @@ export default function WatchTogetherTab({ data }: { data: any }) {
return (
<div
key={i}
className={`wt-queue-item${isCurrent ? ' playing' : ''}${item.watched && !isCurrent ? ' watched' : ''}${isHost ? ' clickable' : ''}`}
onClick={() => isHost && wsSend({ type: 'play_video', index: i })}
title={isHost ? 'Klicken zum Abspielen' : undefined}
className={`wt-queue-item${isCurrent ? ' playing' : ''}${item.watched && !isCurrent ? ' watched' : ''} clickable`}
onClick={() => wsSend({ type: 'play_video', index: i })}
title="Klicken zum Abspielen"
>
<div className="wt-queue-item-info">
{item.watched && !isCurrent && <span className="wt-queue-item-check">{'\u2713'}</span>}

View file

@ -377,6 +377,14 @@
height: 100%;
object-fit: contain;
}
.wt-dm-container {
width: 100%;
height: 100%;
border: none;
position: absolute;
top: 0;
left: 0;
}
.wt-player-placeholder {
display: flex;
flex-direction: column;
@ -545,6 +553,21 @@
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
*/