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
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,8 +830,6 @@ 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'}>
|
<button className="wt-ctrl-btn" onClick={togglePlay} disabled={!currentRoom.currentVideo} title={currentRoom.playing ? 'Pause' : 'Abspielen'}>
|
||||||
{currentRoom.playing ? '\u23F8' : '\u25B6'}
|
{currentRoom.playing ? '\u23F8' : '\u25B6'}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -756,26 +846,6 @@ export default function WatchTogetherTab({ data }: { data: any }) {
|
||||||
onChange={e => seek(parseFloat(e.target.value))}
|
onChange={e => seek(parseFloat(e.target.value))}
|
||||||
disabled={!currentRoom.currentVideo}
|
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ä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>}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
══════════════════════════════════════ */
|
══════════════════════════════════════ */
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue