From 6c57419959dbe8e70b9a7d3726ef1b71421ce406 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 7 Mar 2026 15:31:56 +0100 Subject: [PATCH] Feature: Watch Together - History, Titel-Fetch, Next-Button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server: - Video-History Tracking (max 50 Einträge pro Raum) - History wird bei Skip und Play-Video gespeichert - Server-seitiger Titel-Fetch via noembed.com als Fallback Client: - Aufklappbare History-Sektion im Queue-Panel - "Weiter" Button mit Text-Label statt nur Icon - YouTube-Thumbnails in der Warteschlange - History in RoomState integriert Co-Authored-By: Claude Opus 4.6 --- server/src/plugins/watch-together/index.ts | 47 +++++++++- .../watch-together/WatchTogetherTab.tsx | 39 +++++++- .../plugins/watch-together/watch-together.css | 89 +++++++++++++++++++ 3 files changed, 169 insertions(+), 6 deletions(-) diff --git a/server/src/plugins/watch-together/index.ts b/server/src/plugins/watch-together/index.ts index 2e7b37a..4bb9bec 100644 --- a/server/src/plugins/watch-together/index.ts +++ b/server/src/plugins/watch-together/index.ts @@ -12,6 +12,13 @@ interface QueueItem { addedBy: string; } +interface HistoryItem { + url: string; + title: string; + addedBy: string; + playedAt: string; // ISO timestamp +} + interface RoomMember { id: string; name: string; @@ -29,6 +36,7 @@ interface Room { currentTime: number; lastSyncAt: number; queue: QueueItem[]; + history: HistoryItem[]; } interface WtClient { @@ -78,6 +86,7 @@ function getPlaybackState(room: Room): Record { playing: room.playing, currentTime, updatedAt: Date.now(), + history: room.history, }; } @@ -92,6 +101,7 @@ function serializeRoom(room: Room): Record { playing: room.playing, currentTime: room.currentTime + (room.playing ? (Date.now() - room.lastSyncAt) / 1000 : 0), queue: room.queue, + history: room.history, }; } @@ -176,7 +186,7 @@ function handleDisconnect(client: WtClient): void { // ── WebSocket Message Handler ── -function handleMessage(client: WtClient, msg: any): void { +async function handleMessage(client: WtClient, msg: any): Promise { switch (msg.type) { case 'create_room': { if (client.roomId) { @@ -203,6 +213,7 @@ function handleMessage(client: WtClient, msg: any): void { currentTime: 0, lastSyncAt: Date.now(), queue: [], + history: [], }; rooms.set(roomId, room); @@ -260,7 +271,17 @@ function handleMessage(client: WtClient, msg: any): void { sendTo(client, { type: 'error', code: 'INVALID_URL', message: 'URL darf nicht leer sein.' }); return; } - const title = String(msg.title || url).slice(0, 128); + let title = String(msg.title || '').trim(); + if (!title || title === url) { + try { + const resp = await fetch(`https://noembed.com/embed?url=${encodeURIComponent(url)}`); + if (resp.ok) { + const data = await resp.json() as Record; + if (data.title) title = String(data.title).slice(0, 128); + } + } catch {} + } + if (!title) title = url; room.queue.push({ url, title, addedBy: client.name }); sendToRoom(room.id, { type: 'queue_updated', queue: room.queue }); @@ -325,6 +346,17 @@ function handleMessage(client: WtClient, msg: any): void { return; } + // Track current video in history before replacing + if (room.currentVideo) { + room.history.push({ + url: room.currentVideo.url, + title: room.currentVideo.title, + addedBy: '', + playedAt: new Date().toISOString(), + }); + if (room.history.length > 50) room.history = room.history.slice(-50); + } + const index = msg.index != null ? Number(msg.index) : undefined; if (index !== undefined) { if (index < 0 || index >= room.queue.length) { @@ -405,6 +437,17 @@ function handleMessage(client: WtClient, msg: any): void { sendTo(client, { type: 'error', code: 'NOT_HOST', message: 'Nur der Host kann die Wiedergabe steuern.' }); return; } + // Track current video in history before skipping + if (room.currentVideo) { + room.history.push({ + url: room.currentVideo.url, + title: room.currentVideo.title, + addedBy: '', + playedAt: new Date().toISOString(), + }); + // Keep history max 50 items + if (room.history.length > 50) room.history = room.history.slice(-50); + } if (room.queue.length > 0) { const item = room.queue.shift()!; room.currentVideo = { url: item.url, title: item.title }; diff --git a/web/src/plugins/watch-together/WatchTogetherTab.tsx b/web/src/plugins/watch-together/WatchTogetherTab.tsx index 3ed09ea..be3e6f2 100644 --- a/web/src/plugins/watch-together/WatchTogetherTab.tsx +++ b/web/src/plugins/watch-together/WatchTogetherTab.tsx @@ -21,6 +21,7 @@ interface RoomState { playing: boolean; currentTime: number; queue: Array<{ url: string; title: string; addedBy: string }>; + history: Array<{ url: string; title: string; addedBy: string; playedAt: string }>; } interface JoinModal { @@ -76,6 +77,7 @@ export default function WatchTogetherTab({ data }: { data: any }) { const [currentTime, setCurrentTime] = useState(0); const [playerError, setPlayerError] = useState(null); const [addingToQueue, setAddingToQueue] = useState(false); + const [showHistory, setShowHistory] = useState(false); // ── Refs ── const wsRef = useRef(null); @@ -297,6 +299,7 @@ export default function WatchTogetherTab({ data }: { data: any }) { playing: r.playing || false, currentTime: r.currentTime || 0, queue: r.queue || [], + history: r.history || [], }); break; } @@ -312,6 +315,7 @@ export default function WatchTogetherTab({ data }: { data: any }) { playing: r.playing || false, currentTime: r.currentTime || 0, queue: r.queue || [], + history: r.history || [], }); if (r.currentVideo?.url) { setTimeout(() => loadVideo(r.currentVideo.url), 100); @@ -366,6 +370,7 @@ export default function WatchTogetherTab({ data }: { data: any }) { currentVideo: newVideo || null, playing: msg.playing, currentTime: msg.currentTime ?? prev.currentTime, + history: msg.history ?? prev.history, } : prev); break; } @@ -659,8 +664,8 @@ export default function WatchTogetherTab({ data }: { data: any }) { -
-
{item.title || item.url}
-
{item.addedBy}
+ {item.url.match(/youtu/) && ( + + )} +
+
{item.title || item.url}
+
{item.addedBy}
+
{isHost && ( + {showHistory && ( +
+ {[...currentRoom.history].reverse().map((item, i) => ( +
+
{item.title || item.url}
+
{item.addedBy}
+
+ ))} +
+ )} + + )}