diff --git a/server/src/plugins/watch-together/index.ts b/server/src/plugins/watch-together/index.ts index 12fcf00..52c413f 100644 --- a/server/src/plugins/watch-together/index.ts +++ b/server/src/plugins/watch-together/index.ts @@ -30,6 +30,8 @@ interface Room { currentTime: number; lastSyncAt: number; queue: QueueItem[]; + votes: { skip: Set; pause: Set }; + chatMessages: Array<{ sender: string; text: string; timestamp: number }>; } interface WtClient { @@ -105,6 +107,7 @@ function getRoomList(): Record[] { hasPassword: room.password.length > 0, memberCount: room.members.size, hostName: host?.name ?? 'Unbekannt', + memberNames: [...room.members.values()].map(m => m.name), currentVideo: room.currentVideo, playing: room.playing, }; @@ -157,6 +160,8 @@ function leaveRoom(client: WtClient): void { } room.members.delete(client.id); + room.votes.skip.delete(client.id); + room.votes.pause.delete(client.id); const roomId = client.roomId; client.roomId = null; @@ -175,6 +180,29 @@ function handleDisconnect(client: WtClient): void { leaveRoom(client); } +function checkVotes(room: Room, voteType: 'skip' | 'pause'): boolean { + const needed = Math.ceil(room.members.size / 2); + return room.votes[voteType].size >= needed; +} + +function broadcastVotes(room: Room): void { + sendToRoom(room.id, { + type: 'vote_updated', + votes: { + skip: room.votes.skip.size, + pause: room.votes.pause.size, + total: room.members.size, + needed: Math.ceil(room.members.size / 2), + }, + }); +} + +function resetVotes(room: Room): void { + room.votes.skip.clear(); + room.votes.pause.clear(); + broadcastVotes(room); +} + // ── WebSocket Message Handler ── async function handleMessage(client: WtClient, msg: any): Promise { @@ -204,6 +232,8 @@ async function handleMessage(client: WtClient, msg: any): Promise { currentTime: 0, lastSyncAt: Date.now(), queue: [], + votes: { skip: new Set(), pause: new Set() }, + chatMessages: [], }; rooms.set(roomId, room); @@ -237,6 +267,9 @@ async function handleMessage(client: WtClient, msg: any): Promise { cancelRoomCleanup(room.id); sendTo(client, { type: 'room_joined', room: serializeRoom(room) }); + if (room.chatMessages.length > 0) { + sendTo(client, { type: 'chat_history', messages: room.chatMessages }); + } sendToRoom(room.id, { type: 'members_updated', members: [...room.members.values()], hostId: room.hostId }, client.id); broadcastRoomList(); console.log(`[WatchTogether] ${userName} ist Raum "${room.name}" beigetreten`); @@ -362,6 +395,7 @@ async function handleMessage(client: WtClient, msg: any): Promise { room.lastSyncAt = Date.now(); sendToRoom(room.id, getPlaybackState(room)); sendToRoom(room.id, { type: 'queue_updated', queue: room.queue }); + resetVotes(room); break; } @@ -378,6 +412,7 @@ async function handleMessage(client: WtClient, msg: any): Promise { room.playing = false; room.lastSyncAt = Date.now(); sendToRoom(room.id, getPlaybackState(room)); + resetVotes(room); break; } @@ -392,6 +427,7 @@ async function handleMessage(client: WtClient, msg: any): Promise { room.playing = true; room.lastSyncAt = Date.now(); sendToRoom(room.id, getPlaybackState(room)); + resetVotes(room); break; } @@ -435,6 +471,7 @@ async function handleMessage(client: WtClient, msg: any): Promise { room.lastSyncAt = Date.now(); sendToRoom(room.id, getPlaybackState(room)); sendToRoom(room.id, { type: 'queue_updated', queue: room.queue }); + resetVotes(room); break; } @@ -468,6 +505,83 @@ async function handleMessage(client: WtClient, msg: any): Promise { break; } + case 'vote_skip': { + if (!client.roomId) return; + const room = rooms.get(client.roomId); + if (!room || !room.currentVideo) return; + room.votes.skip.add(client.id); + broadcastVotes(room); + // Check if majority reached + if (checkVotes(room, 'skip')) { + // Execute skip (same logic as skip handler) + if (room.currentVideo) { + const currentItem = room.queue.find(q => q.url === room.currentVideo!.url); + if (currentItem) currentItem.watched = true; + } + const nextItem = room.queue.find(q => !q.watched); + if (nextItem) { + nextItem.watched = true; + room.currentVideo = { url: nextItem.url, title: nextItem.title }; + } else { + room.currentVideo = null; + } + room.playing = room.currentVideo !== null; + room.currentTime = 0; + room.lastSyncAt = Date.now(); + resetVotes(room); + sendToRoom(room.id, getPlaybackState(room)); + sendToRoom(room.id, { type: 'queue_updated', queue: room.queue }); + } + break; + } + + case 'vote_pause': { + if (!client.roomId) return; + const room = rooms.get(client.roomId); + if (!room || !room.currentVideo) return; + room.votes.pause.add(client.id); + broadcastVotes(room); + if (checkVotes(room, 'pause')) { + // Toggle pause/resume + if (room.playing) { + room.currentTime = room.currentTime + (Date.now() - room.lastSyncAt) / 1000; + room.playing = false; + } else { + room.playing = true; + } + room.lastSyncAt = Date.now(); + resetVotes(room); + sendToRoom(room.id, getPlaybackState(room)); + } + break; + } + + case 'chat_message': { + if (!client.roomId) return; + const room = rooms.get(client.roomId); + if (!room) return; + const text = String(msg.text || '').trim().slice(0, 500); + if (!text) return; + const chatMsg = { sender: client.name, text, timestamp: Date.now() }; + room.chatMessages.push(chatMsg); + if (room.chatMessages.length > 100) room.chatMessages.shift(); + sendToRoom(room.id, { type: 'chat', ...chatMsg }); + break; + } + + case 'clear_watched': { + 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 Warteschlange verwalten.' }); + return; + } + room.queue = room.queue.filter(q => !q.watched || (room.currentVideo && q.url === room.currentVideo.url)); + sendToRoom(room.id, { type: 'queue_updated', queue: room.queue }); + break; + } + default: break; } @@ -489,6 +603,25 @@ const watchTogetherPlugin: Plugin = { res.json({ rooms: getRoomList() }); }); + app.get('/api/watch-together/room/:id', (req, res) => { + const room = rooms.get(req.params.id); + if (!room) { + res.status(404).json({ error: 'Raum nicht gefunden.' }); + return; + } + const host = room.members.get(room.hostId); + res.json({ + id: room.id, + name: room.name, + hasPassword: room.password.length > 0, + memberCount: room.members.size, + hostName: host?.name ?? 'Unbekannt', + memberNames: [...room.members.values()].map(m => m.name), + currentVideo: room.currentVideo, + playing: room.playing, + }); + }); + // Fetch video title via noembed.com (CORS-friendly oEmbed proxy) app.get('/api/watch-together/video-info', async (req, res) => { const url = String(req.query.url || ''); diff --git a/web/src/plugins/watch-together/WatchTogetherTab.tsx b/web/src/plugins/watch-together/WatchTogetherTab.tsx index 49cc431..7a3053f 100644 --- a/web/src/plugins/watch-together/WatchTogetherTab.tsx +++ b/web/src/plugins/watch-together/WatchTogetherTab.tsx @@ -8,6 +8,7 @@ interface RoomInfo { name: string; hostName: string; memberCount: number; + memberNames: string[]; hasPassword: boolean; playing: boolean; } @@ -76,6 +77,11 @@ export default function WatchTogetherTab({ data }: { data: any }) { const [currentTime, setCurrentTime] = useState(0); const [playerError, setPlayerError] = useState(null); const [addingToQueue, setAddingToQueue] = useState(false); + const [chatMessages, setChatMessages] = useState>([]); + const [chatInput, setChatInput] = useState(''); + 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); // ── Refs ── const wsRef = useRef(null); @@ -93,6 +99,7 @@ export default function WatchTogetherTab({ data }: { data: any }) { const ytReadyRef = useRef(false); const seekingRef = useRef(false); const timeUpdateRef = useRef | null>(null); + const chatEndRef = useRef(null); // Mirror state to refs useEffect(() => { currentRoomRef.current = currentRoom; }, [currentRoom]); @@ -106,6 +113,22 @@ export default function WatchTogetherTab({ data }: { data: any }) { } }, [data]); + // ── Auto-scroll chat ── + useEffect(() => { + chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [chatMessages]); + + // ── Join via link ── + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const roomId = params.get('wt'); + if (roomId && userName.trim()) { + // Delay to let WS connect first + const timer = setTimeout(() => joinRoom(roomId), 1500); + return () => clearTimeout(timer); + } + }, []); + // ── Save name ── useEffect(() => { if (userName) localStorage.setItem('wt_name', userName); @@ -361,6 +384,17 @@ export default function WatchTogetherTab({ data }: { data: any }) { } } + // Sync status + if (msg.currentTime !== undefined) { + const localTime = getCurrentTime(); + if (localTime !== null) { + const drift = Math.abs(localTime - msg.currentTime); + if (drift < 1.5) setSyncStatus('synced'); + else if (drift < 5) setSyncStatus('drifting'); + else setSyncStatus('desynced'); + } + } + setCurrentRoom(prev => prev ? { ...prev, currentVideo: newVideo || null, @@ -378,6 +412,21 @@ export default function WatchTogetherTab({ data }: { data: any }) { setCurrentRoom(prev => prev ? { ...prev, members: msg.members, hostId: msg.hostId } : prev); break; + case 'vote_updated': + setVotes(msg.votes); + break; + + case 'chat': + setChatMessages(prev => { + const next = [...prev, { sender: msg.sender, text: msg.text, timestamp: msg.timestamp }]; + return next.length > 100 ? next.slice(-100) : next; + }); + break; + + case 'chat_history': + setChatMessages(msg.messages || []); + break; + case 'error': if (msg.code === 'WRONG_PASSWORD') { setJoinModal(prev => prev ? { ...prev, error: msg.message } : prev); @@ -476,6 +525,9 @@ export default function WatchTogetherTab({ data }: { data: any }) { setQueueUrl(''); setDuration(0); setCurrentTime(0); + setChatMessages([]); + setVotes(null); + setSyncStatus('synced'); }, [wsSend, destroyPlayer]); // ── Add to queue ── @@ -498,6 +550,35 @@ export default function WatchTogetherTab({ data }: { data: any }) { setAddingToQueue(false); }, [queueUrl, wsSend]); + // ── Send chat ── + const sendChat = useCallback(() => { + const text = chatInput.trim(); + if (!text) return; + setChatInput(''); + wsSend({ type: 'chat_message', text }); + }, [chatInput, wsSend]); + + // ── Vote callbacks ── + const voteSkip = useCallback(() => { + wsSend({ type: 'vote_skip' }); + }, [wsSend]); + + const votePause = useCallback(() => { + wsSend({ type: 'vote_pause' }); + }, [wsSend]); + + // ── Clear watched ── + const clearWatched = useCallback(() => { + wsSend({ type: 'clear_watched' }); + }, [wsSend]); + + // ── Copy room link ── + const copyRoomLink = useCallback(() => { + if (!currentRoom) return; + const url = `${window.location.origin}${window.location.pathname}?wt=${currentRoom.id}`; + navigator.clipboard.writeText(url).catch(() => {}); + }, [currentRoom]); + // ── Remove from queue ── const removeFromQueue = useCallback((index: number) => { wsSend({ type: 'remove_from_queue', index }); @@ -611,6 +692,9 @@ export default function WatchTogetherTab({ data }: { data: any }) { {hostMember && Host: {hostMember.name}}
+
+ + @@ -675,7 +759,18 @@ export default function WatchTogetherTab({ data }: { data: any }) { ) : ( <> - {currentRoom.playing ? '\u25B6' : '\u23F8'} + + + {votes && votes.skip > 0 && ( + {votes.skip}/{votes.needed} Skip + )} + {votes && votes.pause > 0 && ( + {votes.pause}/{votes.needed} Pause + )}
0 ? `${(currentTime / duration) * 100}%` : '0%' }} />
@@ -698,7 +793,12 @@ export default function WatchTogetherTab({ data }: { data: any }) {
-
Warteschlange ({currentRoom.queue.length})
+
+ Warteschlange ({currentRoom.queue.length}) + {isHost && currentRoom.queue.some(q => q.watched) && ( + + )} +
{currentRoom.queue.length === 0 ? (
Keine Videos in der Warteschlange
@@ -749,6 +849,36 @@ export default function WatchTogetherTab({ data }: { data: any }) {
+ + {showChat && ( +
+
Chat
+
+ {chatMessages.length === 0 ? ( +
Noch keine Nachrichten
+ ) : ( + chatMessages.map((msg, i) => ( +
+ {msg.sender} + {msg.text} +
+ )) + )} +
+
+
+ setChatInput(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') sendChat(); }} + maxLength={500} + /> + +
+
+ )}
); @@ -808,6 +938,12 @@ export default function WatchTogetherTab({ data }: { data: any }) {
{room.name}
{room.hostName}
+ {room.memberNames && room.memberNames.length > 0 && ( +
+ {room.memberNames.slice(0, 5).join(', ')} + {room.memberNames.length > 5 && ` +${room.memberNames.length - 5}`} +
+ )} ))} diff --git a/web/src/plugins/watch-together/watch-together.css b/web/src/plugins/watch-together/watch-together.css index ed4cec1..37a42a9 100644 --- a/web/src/plugins/watch-together/watch-together.css +++ b/web/src/plugins/watch-together/watch-together.css @@ -828,3 +828,174 @@ padding: 6px 12px; white-space: nowrap; } + +/* ══════════════════════════════════════ + SYNC INDICATOR + ══════════════════════════════════════ */ + +.wt-sync-dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; +} +.wt-sync-synced { background: #2ecc71; box-shadow: 0 0 6px rgba(46, 204, 113, 0.5); } +.wt-sync-drifting { background: #f1c40f; box-shadow: 0 0 6px rgba(241, 196, 15, 0.5); } +.wt-sync-desynced { background: #e74c3c; box-shadow: 0 0 6px rgba(231, 76, 60, 0.5); } + +/* ══════════════════════════════════════ + VOTE BUTTONS + ══════════════════════════════════════ */ + +.wt-vote-btn { + background: rgba(255, 255, 255, 0.08) !important; + border: 1px solid rgba(255, 255, 255, 0.15) !important; +} +.wt-vote-btn:hover:not(:disabled) { + background: rgba(230, 126, 34, 0.2) !important; + border-color: var(--accent) !important; +} +.wt-vote-count { + font-size: 12px; + color: var(--accent); + font-weight: 600; + white-space: nowrap; + padding: 2px 8px; + background: rgba(230, 126, 34, 0.12); + border-radius: 4px; +} + +/* ══════════════════════════════════════ + HEADER BUTTONS + ══════════════════════════════════════ */ + +.wt-header-btn { + background: rgba(255, 255, 255, 0.1); + border: none; + color: #fff; + padding: 8px 14px; + border-radius: var(--radius); + cursor: pointer; + font-size: 13px; + transition: background var(--transition); +} +.wt-header-btn:hover { + background: rgba(255, 255, 255, 0.2); +} + +/* ══════════════════════════════════════ + CHAT PANEL + ══════════════════════════════════════ */ + +.wt-chat-panel { + width: 260px; + background: var(--bg-secondary); + border-left: 1px solid var(--bg-tertiary); + display: flex; + flex-direction: column; + flex-shrink: 0; +} +.wt-chat-header { + padding: 14px 16px; + font-weight: 600; + font-size: 14px; + color: var(--text-normal); + border-bottom: 1px solid var(--bg-tertiary); + flex-shrink: 0; +} +.wt-chat-messages { + flex: 1; + overflow-y: auto; + padding: 8px 12px; + scrollbar-width: thin; + scrollbar-color: var(--bg-tertiary) transparent; +} +.wt-chat-messages::-webkit-scrollbar { width: 4px; } +.wt-chat-messages::-webkit-scrollbar-thumb { background: var(--bg-tertiary); border-radius: 2px; } +.wt-chat-empty { + padding: 24px 8px; + text-align: center; + color: var(--text-faint); + font-size: 13px; +} +.wt-chat-msg { + margin-bottom: 6px; + font-size: 13px; + line-height: 1.4; + word-break: break-word; +} +.wt-chat-sender { + font-weight: 600; + color: var(--accent); + margin-right: 6px; +} +.wt-chat-text { + color: var(--text-normal); +} +.wt-chat-input-row { + padding: 10px 12px; + display: flex; + gap: 6px; + border-top: 1px solid var(--bg-tertiary); + flex-shrink: 0; +} +.wt-chat-input { + flex: 1; + font-size: 13px; + padding: 8px 10px; +} +.wt-chat-send-btn { + padding: 8px 12px; + font-size: 13px; + flex-shrink: 0; +} + +/* ══════════════════════════════════════ + QUEUE HEADER CLEAR BUTTON + ══════════════════════════════════════ */ + +.wt-queue-header { + display: flex; + align-items: center; + justify-content: space-between; +} +.wt-queue-clear-btn { + background: none; + border: none; + color: var(--text-faint); + font-size: 11px; + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; + transition: all var(--transition); +} +.wt-queue-clear-btn:hover { + color: var(--danger); + background: rgba(237, 66, 69, 0.12); +} + +/* ══════════════════════════════════════ + TILE MEMBER NAMES + ══════════════════════════════════════ */ + +.wt-tile-members-list { + font-size: 11px; + color: var(--text-faint); + padding: 0 12px 8px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ══════════════════════════════════════ + RESPONSIVE CHAT + ══════════════════════════════════════ */ + +@media (max-width: 768px) { + .wt-chat-panel { + width: 100%; + max-height: 30vh; + border-left: none; + border-top: 1px solid var(--bg-tertiary); + } +}