Watch Together: Embed-Fehlerbehandlung, klickbare Queue, Video-Titel

- YouTube onError Handler: Erkennt Error 101/150 (Embedding deaktiviert),
  zeigt Fehlermeldung + "Auf YouTube oeffnen" Link, auto-skip nach 3s
- Queue-Items klickbar fuer Host (play_video mit Index)
- Video-Titel werden via noembed.com oEmbed API geholt
- Server-Endpoint: GET /api/watch-together/video-info?url=...
- "Hinzufuegen" Button zeigt Ladezustand waehrend Titel-Fetch

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-03-07 11:19:59 +01:00
parent e4895a792c
commit 09813b626f
7 changed files with 4967 additions and 4838 deletions

View file

@ -470,6 +470,27 @@ const watchTogetherPlugin: Plugin = {
res.json({ rooms: getRoomList() });
});
// 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 || '');
if (!url) {
res.json({ title: '' });
return;
}
try {
const response = await fetch(`https://noembed.com/embed?url=${encodeURIComponent(url)}`);
if (response.ok) {
const data = await response.json() as Record<string, any>;
if (data.title) {
res.json({ title: data.title, provider: data.provider_name || '' });
return;
}
}
} catch { /* ignore fetch errors */ }
// Fallback: return empty title (frontend will use URL)
res.json({ title: '' });
});
// Beacon cleanup endpoint — called via navigator.sendBeacon() on page unload
app.post('/api/watch-together/disconnect', (req, res) => {
let body = '';

4830
web/dist/assets/index-Be3HasqO.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
web/dist/index.html vendored
View file

@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Gaming Hub</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎮</text></svg>" />
<script type="module" crossorigin src="/assets/index-DKV7w-rf.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C2eno-Si.css">
<script type="module" crossorigin src="/assets/index-Be3HasqO.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DEfJ3Ric.css">
</head>
<body>
<div id="root"></div>

View file

@ -74,6 +74,8 @@ export default function WatchTogetherTab({ data }: { data: any }) {
const [isFullscreen, setIsFullscreen] = useState(false);
const [duration, setDuration] = useState(0);
const [currentTime, setCurrentTime] = useState(0);
const [playerError, setPlayerError] = useState<string | null>(null);
const [addingToQueue, setAddingToQueue] = useState(false);
// ── Refs ──
const wsRef = useRef<WebSocket | null>(null);
@ -183,6 +185,7 @@ export default function WatchTogetherTab({ data }: { data: any }) {
// ── Load video ──
const loadVideo = useCallback((url: string) => {
destroyPlayer();
setPlayerError(null);
const parsed = parseVideoUrl(url);
if (!parsed) return;
@ -220,6 +223,26 @@ export default function WatchTogetherTab({ data }: { data: any }) {
}
}
},
onError: (ev: any) => {
const code = ev.data;
// 101 or 150 = embedding disabled by video owner
// 100 = video not found
// 5 = HTML5 player error
if (code === 101 || code === 150) {
setPlayerError('Embedding deaktiviert \u2014 Video kann nur auf YouTube angesehen werden.');
} else if (code === 100) {
setPlayerError('Video nicht gefunden oder entfernt.');
} else {
setPlayerError('Wiedergabefehler \u2014 Video wird \u00FCbersprungen.');
}
// Auto-skip after 3 seconds if host
setTimeout(() => {
const room = currentRoomRef.current;
if (room && clientIdRef.current === room.hostId) {
wsSend({ type: 'skip' });
}
}, 3000);
},
},
});
} else {
@ -456,10 +479,23 @@ export default function WatchTogetherTab({ data }: { data: any }) {
}, [wsSend, destroyPlayer]);
// ── Add to queue ──
const addToQueue = useCallback(() => {
if (!queueUrl.trim()) return;
wsSend({ type: 'add_to_queue', url: queueUrl.trim() });
const addToQueue = useCallback(async () => {
const url = queueUrl.trim();
if (!url) return;
setQueueUrl('');
setAddingToQueue(true);
let title = '';
try {
const resp = await fetch(`/api/watch-together/video-info?url=${encodeURIComponent(url)}`);
if (resp.ok) {
const data = await resp.json();
title = data.title || '';
}
} catch { /* use URL as fallback */ }
wsSend({ type: 'add_to_queue', url, title: title || undefined });
setAddingToQueue(false);
}, [queueUrl, wsSend]);
// ── Remove from queue ──
@ -592,6 +628,23 @@ export default function WatchTogetherTab({ data }: { data: any }) {
style={currentVideoTypeRef.current === 'direct' ? {} : { display: 'none' }}
playsInline
/>
{playerError && (
<div className="wt-player-error">
<div className="wt-error-icon">{'\u26A0\uFE0F'}</div>
<p>{playerError}</p>
{currentRoom.currentVideo?.url && (
<a
className="wt-yt-link"
href={currentRoom.currentVideo.url}
target="_blank"
rel="noopener noreferrer"
>
Auf YouTube &ouml;ffnen &#8599;
</a>
)}
<p className="wt-skip-info">Wird in 3 Sekunden &uuml;bersprungen...</p>
</div>
)}
{!currentRoom.currentVideo && (
<div className="wt-player-placeholder">
<div className="wt-placeholder-icon">{'\uD83C\uDFAC'}</div>
@ -653,7 +706,9 @@ export default function WatchTogetherTab({ data }: { data: any }) {
currentRoom.queue.map((item, i) => (
<div
key={i}
className={`wt-queue-item${currentRoom.currentVideo?.url === item.url ? ' playing' : ''}`}
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">
<div className="wt-queue-item-title">{item.title || item.url}</div>
@ -676,7 +731,9 @@ export default function WatchTogetherTab({ data }: { data: any }) {
onChange={e => setQueueUrl(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') addToQueue(); }}
/>
<button className="wt-btn wt-queue-add-btn" onClick={addToQueue}>Hinzufuegen</button>
<button className="wt-btn wt-queue-add-btn" onClick={addToQueue} disabled={addingToQueue}>
{addingToQueue ? 'Laden...' : 'Hinzufuegen'}
</button>
</div>
</div>
</div>

View file

@ -651,6 +651,57 @@
flex-shrink: 0;
}
/* ── Player Error Overlay ── */
.wt-player-error {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
background: rgba(0, 0, 0, 0.85);
color: #fff;
z-index: 2;
text-align: center;
padding: 20px;
}
.wt-error-icon {
font-size: 48px;
}
.wt-player-error p {
font-size: 15px;
color: var(--text-muted);
margin: 0;
}
.wt-yt-link {
display: inline-block;
padding: 8px 20px;
background: #ff0000;
color: #fff;
border-radius: var(--radius);
text-decoration: none;
font-weight: 600;
font-size: 14px;
transition: background var(--transition);
}
.wt-yt-link:hover {
background: #cc0000;
}
.wt-skip-info {
font-size: 12px !important;
color: var(--text-faint) !important;
font-style: italic;
}
/* ── Clickable Queue Items ── */
.wt-queue-item.clickable {
cursor: pointer;
}
.wt-queue-item.clickable:hover {
background: rgba(230, 126, 34, 0.12);
}
/*
RESPONSIVE
*/