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:
parent
7abd5551d0
commit
253a249fc7
3 changed files with 154 additions and 64 deletions
|
|
@ -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,44 +830,22 @@ 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>
|
||||
<button className="wt-ctrl-btn wt-next-btn" onClick={skip} disabled={!currentRoom.currentVideo && currentRoom.queue.length === 0} title="Nächstes Video">
|
||||
{'\u23ED'} Weiter
|
||||
</button>
|
||||
<input
|
||||
className="wt-seek"
|
||||
type="range"
|
||||
min={0}
|
||||
max={duration || 0}
|
||||
step={0.5}
|
||||
value={currentTime}
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
<button className="wt-ctrl-btn" onClick={togglePlay} disabled={!currentRoom.currentVideo} title={currentRoom.playing ? 'Pause' : 'Abspielen'}>
|
||||
{currentRoom.playing ? '\u23F8' : '\u25B6'}
|
||||
</button>
|
||||
<button className="wt-ctrl-btn wt-next-btn" onClick={skip} disabled={!currentRoom.currentVideo && currentRoom.queue.length === 0} title="Nächstes Video">
|
||||
{'\u23ED'} Weiter
|
||||
</button>
|
||||
<input
|
||||
className="wt-seek"
|
||||
type="range"
|
||||
min={0}
|
||||
max={duration || 0}
|
||||
step={0.5}
|
||||
value={currentTime}
|
||||
onChange={e => seek(parseFloat(e.target.value))}
|
||||
disabled={!currentRoom.currentVideo}
|
||||
/>
|
||||
<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ä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>}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
══════════════════════════════════════ */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue