Watch Together: Videos bleiben in Queue mit Watched-Haken

Statt Videos nach dem Abspielen aus der Warteschlange zu entfernen,
bleiben sie drin und werden mit einem gruenen Haken markiert.
Separate History-Section entfernt — die Queue IST die History.
Videos bleiben klickbar zum erneuten Abspielen.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-03-07 22:23:43 +01:00
parent 22b4c6b187
commit 963bb1b775
3 changed files with 64 additions and 146 deletions

View file

@ -10,13 +10,7 @@ interface QueueItem {
url: string;
title: string;
addedBy: string;
}
interface HistoryItem {
url: string;
title: string;
addedBy: string;
playedAt: string; // ISO timestamp
watched: boolean;
}
interface RoomMember {
@ -36,7 +30,6 @@ interface Room {
currentTime: number;
lastSyncAt: number;
queue: QueueItem[];
history: HistoryItem[];
}
interface WtClient {
@ -86,7 +79,6 @@ function getPlaybackState(room: Room): Record<string, any> {
playing: room.playing,
currentTime,
updatedAt: Date.now(),
history: room.history,
};
}
@ -101,7 +93,6 @@ function serializeRoom(room: Room): Record<string, any> {
playing: room.playing,
currentTime: room.currentTime + (room.playing ? (Date.now() - room.lastSyncAt) / 1000 : 0),
queue: room.queue,
history: room.history,
};
}
@ -213,7 +204,6 @@ async function handleMessage(client: WtClient, msg: any): Promise<void> {
currentTime: 0,
lastSyncAt: Date.now(),
queue: [],
history: [],
};
rooms.set(roomId, room);
@ -283,19 +273,19 @@ async function handleMessage(client: WtClient, msg: any): Promise<void> {
}
if (!title) title = url;
room.queue.push({ url, title, addedBy: client.name });
sendToRoom(room.id, { type: 'queue_updated', queue: room.queue });
const queueItem: QueueItem = { url, title, addedBy: client.name, watched: false };
room.queue.push(queueItem);
// Auto-play first item if nothing is currently playing
if (!room.currentVideo) {
const item = room.queue.shift()!;
room.currentVideo = { url: item.url, title: item.title };
queueItem.watched = true;
room.currentVideo = { url: queueItem.url, title: queueItem.title };
room.playing = true;
room.currentTime = 0;
room.lastSyncAt = Date.now();
sendToRoom(room.id, getPlaybackState(room));
sendToRoom(room.id, { type: 'queue_updated', queue: room.queue });
}
sendToRoom(room.id, { type: 'queue_updated', queue: room.queue });
break;
}
@ -346,34 +336,24 @@ async function handleMessage(client: WtClient, msg: any): Promise<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) {
sendTo(client, { type: 'error', code: 'INVALID_INDEX', message: 'Ungültiger Index.' });
return;
}
const [item] = room.queue.splice(index, 1);
room.currentVideo = { url: item.url, title: item.title };
room.queue[index].watched = true;
room.currentVideo = { url: room.queue[index].url, title: room.queue[index].title };
} else {
// No index — play from queue head or error if nothing available
if (!room.currentVideo && room.queue.length === 0) {
sendTo(client, { type: 'error', code: 'QUEUE_EMPTY', message: 'Warteschlange ist leer und kein Video ausgewählt.' });
// No index — play next unwatched or error
const nextItem = room.queue.find(q => !q.watched);
if (!room.currentVideo && !nextItem) {
sendTo(client, { type: 'error', code: 'QUEUE_EMPTY', message: 'Keine ungesehenen Videos in der Warteschlange.' });
return;
}
if (!room.currentVideo && room.queue.length > 0) {
const item = room.queue.shift()!;
room.currentVideo = { url: item.url, title: item.title };
if (nextItem) {
nextItem.watched = true;
room.currentVideo = { url: nextItem.url, title: nextItem.title };
}
}
@ -437,20 +417,16 @@ async function handleMessage(client: WtClient, msg: any): Promise<void> {
sendTo(client, { type: 'error', code: 'NOT_HOST', message: 'Nur der Host kann die Wiedergabe steuern.' });
return;
}
// Track current video in history before skipping
// Mark current video as watched in queue
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);
const currentItem = room.queue.find(q => q.url === room.currentVideo!.url);
if (currentItem) currentItem.watched = true;
}
if (room.queue.length > 0) {
const item = room.queue.shift()!;
room.currentVideo = { url: item.url, title: item.title };
// Find next unwatched video
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;
}

View file

@ -20,8 +20,7 @@ interface RoomState {
currentVideo: { url: string; title: string } | null;
playing: boolean;
currentTime: number;
queue: Array<{ url: string; title: string; addedBy: string }>;
history: Array<{ url: string; title: string; addedBy: string; playedAt: string }>;
queue: Array<{ url: string; title: string; addedBy: string; watched: boolean }>;
}
interface JoinModal {
@ -77,7 +76,6 @@ export default function WatchTogetherTab({ data }: { data: any }) {
const [currentTime, setCurrentTime] = useState(0);
const [playerError, setPlayerError] = useState<string | null>(null);
const [addingToQueue, setAddingToQueue] = useState(false);
const [showHistory, setShowHistory] = useState(false);
// ── Refs ──
const wsRef = useRef<WebSocket | null>(null);
@ -299,7 +297,6 @@ export default function WatchTogetherTab({ data }: { data: any }) {
playing: r.playing || false,
currentTime: r.currentTime || 0,
queue: r.queue || [],
history: r.history || [],
});
break;
}
@ -315,7 +312,6 @@ 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);
@ -370,7 +366,6 @@ 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;
}
@ -708,52 +703,39 @@ export default function WatchTogetherTab({ data }: { data: any }) {
{currentRoom.queue.length === 0 ? (
<div className="wt-queue-empty">Keine Videos in der Warteschlange</div>
) : (
currentRoom.queue.map((item, i) => (
<div
key={i}
className={`wt-queue-item${currentRoom.currentVideo?.url === item.url ? ' playing' : ''}${isHost ? ' clickable' : ''}`}
onClick={() => isHost && wsSend({ type: 'play_video', index: i })}
title={isHost ? 'Klicken zum Abspielen' : undefined}
>
<div className="wt-queue-item-info">
{item.url.match(/youtu/) && (
<img
className="wt-queue-thumb"
src={`https://img.youtube.com/vi/${item.url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/)?.[1]}/default.jpg`}
alt=""
/>
)}
<div className="wt-queue-item-text">
<div className="wt-queue-item-title">{item.title || item.url}</div>
<div className="wt-queue-item-by">{item.addedBy}</div>
currentRoom.queue.map((item, i) => {
const isCurrent = currentRoom.currentVideo?.url === item.url;
return (
<div
key={i}
className={`wt-queue-item${isCurrent ? ' playing' : ''}${item.watched && !isCurrent ? ' watched' : ''}${isHost ? ' clickable' : ''}`}
onClick={() => isHost && wsSend({ type: 'play_video', index: i })}
title={isHost ? 'Klicken zum Abspielen' : undefined}
>
<div className="wt-queue-item-info">
{item.watched && !isCurrent && <span className="wt-queue-item-check">{'\u2713'}</span>}
{item.url.match(/youtu/) && (
<img
className="wt-queue-thumb"
src={`https://img.youtube.com/vi/${item.url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/)?.[1]}/default.jpg`}
alt=""
/>
)}
<div className="wt-queue-item-text">
<div className="wt-queue-item-title">{item.title || item.url}</div>
<div className="wt-queue-item-by">{item.addedBy}</div>
</div>
</div>
{isHost && (
<button className="wt-queue-item-remove" onClick={(e) => { e.stopPropagation(); removeFromQueue(i); }} title="Entfernen">
{'\u00D7'}
</button>
)}
</div>
{isHost && (
<button className="wt-queue-item-remove" onClick={() => removeFromQueue(i)} title="Entfernen">
{'\u00D7'}
</button>
)}
</div>
))
);
})
)}
</div>
{currentRoom.history && currentRoom.history.length > 0 && (
<div className="wt-history-section">
<button className="wt-history-toggle" onClick={() => setShowHistory(!showHistory)}>
{showHistory ? '\u25BC' : '\u25B6'} Verlauf ({currentRoom.history.length})
</button>
{showHistory && (
<div className="wt-history-list">
{[...currentRoom.history].reverse().map((item, i) => (
<div key={i} className="wt-history-item">
<div className="wt-history-item-title">{item.title || item.url}</div>
<div className="wt-history-item-by">{item.addedBy}</div>
</div>
))}
</div>
)}
</div>
)}
<div className="wt-queue-add">
<input
className="wt-input wt-queue-input"

View file

@ -780,61 +780,21 @@
}
}
/* ── History Section ── */
.wt-history-section {
border-top: 1px solid var(--bg-tertiary);
margin-top: 8px;
padding-top: 8px;
}
.wt-history-toggle {
width: 100%;
background: none;
border: none;
color: var(--text-muted);
font-size: 13px;
cursor: pointer;
padding: 6px 8px;
text-align: left;
border-radius: var(--radius);
transition: background var(--transition), color var(--transition);
}
.wt-history-toggle:hover {
background: var(--bg-tertiary);
color: var(--text-normal);
}
.wt-history-list {
max-height: 200px;
overflow-y: auto;
margin-top: 4px;
}
.wt-history-item {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 10px;
border-radius: var(--radius);
opacity: 0.6;
/* ── Watched Queue Items ── */
.wt-queue-item.watched {
opacity: 0.55;
transition: opacity var(--transition), background var(--transition);
}
.wt-history-item:hover {
opacity: 1;
background: var(--bg-tertiary);
.wt-queue-item.watched:hover {
opacity: 0.85;
}
.wt-history-item-title {
font-size: 13px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.wt-history-item-by {
font-size: 11px;
color: var(--text-faint);
.wt-queue-item-check {
color: #2ecc71;
font-size: 16px;
font-weight: 700;
flex-shrink: 0;
line-height: 1;
}
/* ── Queue Thumbnail ── */