Feature: Watch Together - History, Titel-Fetch, Next-Button
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 <noreply@anthropic.com>
This commit is contained in:
parent
a1a1f31c8e
commit
6c57419959
3 changed files with 169 additions and 6 deletions
|
|
@ -12,6 +12,13 @@ interface QueueItem {
|
||||||
addedBy: string;
|
addedBy: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface HistoryItem {
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
addedBy: string;
|
||||||
|
playedAt: string; // ISO timestamp
|
||||||
|
}
|
||||||
|
|
||||||
interface RoomMember {
|
interface RoomMember {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -29,6 +36,7 @@ interface Room {
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
lastSyncAt: number;
|
lastSyncAt: number;
|
||||||
queue: QueueItem[];
|
queue: QueueItem[];
|
||||||
|
history: HistoryItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WtClient {
|
interface WtClient {
|
||||||
|
|
@ -78,6 +86,7 @@ function getPlaybackState(room: Room): Record<string, any> {
|
||||||
playing: room.playing,
|
playing: room.playing,
|
||||||
currentTime,
|
currentTime,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
|
history: room.history,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -92,6 +101,7 @@ function serializeRoom(room: Room): Record<string, any> {
|
||||||
playing: room.playing,
|
playing: room.playing,
|
||||||
currentTime: room.currentTime + (room.playing ? (Date.now() - room.lastSyncAt) / 1000 : 0),
|
currentTime: room.currentTime + (room.playing ? (Date.now() - room.lastSyncAt) / 1000 : 0),
|
||||||
queue: room.queue,
|
queue: room.queue,
|
||||||
|
history: room.history,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -176,7 +186,7 @@ function handleDisconnect(client: WtClient): void {
|
||||||
|
|
||||||
// ── WebSocket Message Handler ──
|
// ── WebSocket Message Handler ──
|
||||||
|
|
||||||
function handleMessage(client: WtClient, msg: any): void {
|
async function handleMessage(client: WtClient, msg: any): Promise<void> {
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case 'create_room': {
|
case 'create_room': {
|
||||||
if (client.roomId) {
|
if (client.roomId) {
|
||||||
|
|
@ -203,6 +213,7 @@ function handleMessage(client: WtClient, msg: any): void {
|
||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
lastSyncAt: Date.now(),
|
lastSyncAt: Date.now(),
|
||||||
queue: [],
|
queue: [],
|
||||||
|
history: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
rooms.set(roomId, room);
|
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.' });
|
sendTo(client, { type: 'error', code: 'INVALID_URL', message: 'URL darf nicht leer sein.' });
|
||||||
return;
|
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<string, any>;
|
||||||
|
if (data.title) title = String(data.title).slice(0, 128);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
if (!title) title = url;
|
||||||
|
|
||||||
room.queue.push({ url, title, addedBy: client.name });
|
room.queue.push({ url, title, addedBy: client.name });
|
||||||
sendToRoom(room.id, { type: 'queue_updated', queue: room.queue });
|
sendToRoom(room.id, { type: 'queue_updated', queue: room.queue });
|
||||||
|
|
@ -325,6 +346,17 @@ function handleMessage(client: WtClient, msg: any): void {
|
||||||
return;
|
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;
|
const index = msg.index != null ? Number(msg.index) : undefined;
|
||||||
if (index !== undefined) {
|
if (index !== undefined) {
|
||||||
if (index < 0 || index >= room.queue.length) {
|
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.' });
|
sendTo(client, { type: 'error', code: 'NOT_HOST', message: 'Nur der Host kann die Wiedergabe steuern.' });
|
||||||
return;
|
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) {
|
if (room.queue.length > 0) {
|
||||||
const item = room.queue.shift()!;
|
const item = room.queue.shift()!;
|
||||||
room.currentVideo = { url: item.url, title: item.title };
|
room.currentVideo = { url: item.url, title: item.title };
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ interface RoomState {
|
||||||
playing: boolean;
|
playing: boolean;
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
queue: Array<{ url: string; title: string; addedBy: string }>;
|
queue: Array<{ url: string; title: string; addedBy: string }>;
|
||||||
|
history: Array<{ url: string; title: string; addedBy: string; playedAt: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface JoinModal {
|
interface JoinModal {
|
||||||
|
|
@ -76,6 +77,7 @@ export default function WatchTogetherTab({ data }: { data: any }) {
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
const [playerError, setPlayerError] = useState<string | null>(null);
|
const [playerError, setPlayerError] = useState<string | null>(null);
|
||||||
const [addingToQueue, setAddingToQueue] = useState(false);
|
const [addingToQueue, setAddingToQueue] = useState(false);
|
||||||
|
const [showHistory, setShowHistory] = useState(false);
|
||||||
|
|
||||||
// ── Refs ──
|
// ── Refs ──
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
|
@ -297,6 +299,7 @@ export default function WatchTogetherTab({ data }: { data: any }) {
|
||||||
playing: r.playing || false,
|
playing: r.playing || false,
|
||||||
currentTime: r.currentTime || 0,
|
currentTime: r.currentTime || 0,
|
||||||
queue: r.queue || [],
|
queue: r.queue || [],
|
||||||
|
history: r.history || [],
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -312,6 +315,7 @@ export default function WatchTogetherTab({ data }: { data: any }) {
|
||||||
playing: r.playing || false,
|
playing: r.playing || false,
|
||||||
currentTime: r.currentTime || 0,
|
currentTime: r.currentTime || 0,
|
||||||
queue: r.queue || [],
|
queue: r.queue || [],
|
||||||
|
history: r.history || [],
|
||||||
});
|
});
|
||||||
if (r.currentVideo?.url) {
|
if (r.currentVideo?.url) {
|
||||||
setTimeout(() => loadVideo(r.currentVideo.url), 100);
|
setTimeout(() => loadVideo(r.currentVideo.url), 100);
|
||||||
|
|
@ -366,6 +370,7 @@ export default function WatchTogetherTab({ data }: { data: any }) {
|
||||||
currentVideo: newVideo || null,
|
currentVideo: newVideo || null,
|
||||||
playing: msg.playing,
|
playing: msg.playing,
|
||||||
currentTime: msg.currentTime ?? prev.currentTime,
|
currentTime: msg.currentTime ?? prev.currentTime,
|
||||||
|
history: msg.history ?? prev.history,
|
||||||
} : prev);
|
} : prev);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -659,8 +664,8 @@ export default function WatchTogetherTab({ data }: { data: any }) {
|
||||||
<button className="wt-ctrl-btn" onClick={togglePlay} disabled={!currentRoom.currentVideo} title={currentRoom.playing ? 'Pause' : 'Abspielen'}>
|
<button className="wt-ctrl-btn" onClick={togglePlay} disabled={!currentRoom.currentVideo} title={currentRoom.playing ? 'Pause' : 'Abspielen'}>
|
||||||
{currentRoom.playing ? '\u23F8' : '\u25B6'}
|
{currentRoom.playing ? '\u23F8' : '\u25B6'}
|
||||||
</button>
|
</button>
|
||||||
<button className="wt-ctrl-btn" onClick={skip} disabled={!currentRoom.currentVideo} title="Weiter">
|
<button className="wt-ctrl-btn wt-next-btn" onClick={skip} disabled={!currentRoom.currentVideo && currentRoom.queue.length === 0} title="Nächstes Video">
|
||||||
{'\u23ED'}
|
{'\u23ED'} Weiter
|
||||||
</button>
|
</button>
|
||||||
<input
|
<input
|
||||||
className="wt-seek"
|
className="wt-seek"
|
||||||
|
|
@ -711,8 +716,17 @@ export default function WatchTogetherTab({ data }: { data: any }) {
|
||||||
title={isHost ? 'Klicken zum Abspielen' : undefined}
|
title={isHost ? 'Klicken zum Abspielen' : undefined}
|
||||||
>
|
>
|
||||||
<div className="wt-queue-item-info">
|
<div className="wt-queue-item-info">
|
||||||
<div className="wt-queue-item-title">{item.title || item.url}</div>
|
{item.url.match(/youtu/) && (
|
||||||
<div className="wt-queue-item-by">{item.addedBy}</div>
|
<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>
|
</div>
|
||||||
{isHost && (
|
{isHost && (
|
||||||
<button className="wt-queue-item-remove" onClick={() => removeFromQueue(i)} title="Entfernen">
|
<button className="wt-queue-item-remove" onClick={() => removeFromQueue(i)} title="Entfernen">
|
||||||
|
|
@ -723,6 +737,23 @@ export default function WatchTogetherTab({ data }: { data: any }) {
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</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">
|
<div className="wt-queue-add">
|
||||||
<input
|
<input
|
||||||
className="wt-input wt-queue-input"
|
className="wt-input wt-queue-input"
|
||||||
|
|
|
||||||
|
|
@ -779,3 +779,92 @@
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 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;
|
||||||
|
transition: opacity var(--transition), background var(--transition);
|
||||||
|
}
|
||||||
|
.wt-history-item:hover {
|
||||||
|
opacity: 1;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Queue Thumbnail ── */
|
||||||
|
.wt-queue-item-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-queue-thumb {
|
||||||
|
width: 48px;
|
||||||
|
height: 36px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-queue-item-text {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Next Button ── */
|
||||||
|
.wt-next-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue