Feature: Watch Together - Chat, Voting, Sync-Indicator, Join-per-Link

- Raumliste zeigt jetzt Teilnehmernamen
- Join per Link (?wt=roomId) für einfaches Teilen
- Sync-Indikator (grün/gelb/rot) zeigt Synchronstatus
- Pause/Skip-Voting für Nicht-Host-Teilnehmer
- In-Room Chat mit Nachrichtenverlauf
- "Gesehene entfernen" Button für Host in der Queue
- REST endpoint GET /api/watch-together/room/:id

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-03-07 22:38:51 +01:00
parent c79a8675b0
commit 8aefb49ff3
3 changed files with 442 additions and 2 deletions

View file

@ -30,6 +30,8 @@ interface Room {
currentTime: number;
lastSyncAt: number;
queue: QueueItem[];
votes: { skip: Set<string>; pause: Set<string> };
chatMessages: Array<{ sender: string; text: string; timestamp: number }>;
}
interface WtClient {
@ -105,6 +107,7 @@ function getRoomList(): Record<string, any>[] {
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<void> {
@ -204,6 +232,8 @@ async function handleMessage(client: WtClient, msg: any): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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 || '');