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:
parent
c79a8675b0
commit
8aefb49ff3
3 changed files with 442 additions and 2 deletions
|
|
@ -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 || '');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue