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:
parent
e4895a792c
commit
09813b626f
7 changed files with 4967 additions and 4838 deletions
|
|
@ -470,6 +470,27 @@ const watchTogetherPlugin: Plugin = {
|
||||||
res.json({ rooms: getRoomList() });
|
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
|
// Beacon cleanup endpoint — called via navigator.sendBeacon() on page unload
|
||||||
app.post('/api/watch-together/disconnect', (req, res) => {
|
app.post('/api/watch-together/disconnect', (req, res) => {
|
||||||
let body = '';
|
let body = '';
|
||||||
|
|
|
||||||
4830
web/dist/assets/index-Be3HasqO.js
vendored
Normal file
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
4830
web/dist/assets/index-DKV7w-rf.js
vendored
4830
web/dist/assets/index-DKV7w-rf.js
vendored
File diff suppressed because one or more lines are too long
4
web/dist/index.html
vendored
4
web/dist/index.html
vendored
|
|
@ -5,8 +5,8 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Gaming Hub</title>
|
<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>" />
|
<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>
|
<script type="module" crossorigin src="/assets/index-Be3HasqO.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-C2eno-Si.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-DEfJ3Ric.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,8 @@ export default function WatchTogetherTab({ data }: { data: any }) {
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
const [duration, setDuration] = useState(0);
|
const [duration, setDuration] = useState(0);
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [playerError, setPlayerError] = useState<string | null>(null);
|
||||||
|
const [addingToQueue, setAddingToQueue] = useState(false);
|
||||||
|
|
||||||
// ── Refs ──
|
// ── Refs ──
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
|
@ -183,6 +185,7 @@ export default function WatchTogetherTab({ data }: { data: any }) {
|
||||||
// ── Load video ──
|
// ── Load video ──
|
||||||
const loadVideo = useCallback((url: string) => {
|
const loadVideo = useCallback((url: string) => {
|
||||||
destroyPlayer();
|
destroyPlayer();
|
||||||
|
setPlayerError(null);
|
||||||
const parsed = parseVideoUrl(url);
|
const parsed = parseVideoUrl(url);
|
||||||
if (!parsed) return;
|
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 {
|
} else {
|
||||||
|
|
@ -456,10 +479,23 @@ export default function WatchTogetherTab({ data }: { data: any }) {
|
||||||
}, [wsSend, destroyPlayer]);
|
}, [wsSend, destroyPlayer]);
|
||||||
|
|
||||||
// ── Add to queue ──
|
// ── Add to queue ──
|
||||||
const addToQueue = useCallback(() => {
|
const addToQueue = useCallback(async () => {
|
||||||
if (!queueUrl.trim()) return;
|
const url = queueUrl.trim();
|
||||||
wsSend({ type: 'add_to_queue', url: queueUrl.trim() });
|
if (!url) return;
|
||||||
setQueueUrl('');
|
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]);
|
}, [queueUrl, wsSend]);
|
||||||
|
|
||||||
// ── Remove from queue ──
|
// ── Remove from queue ──
|
||||||
|
|
@ -592,6 +628,23 @@ export default function WatchTogetherTab({ data }: { data: any }) {
|
||||||
style={currentVideoTypeRef.current === 'direct' ? {} : { display: 'none' }}
|
style={currentVideoTypeRef.current === 'direct' ? {} : { display: 'none' }}
|
||||||
playsInline
|
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 öffnen ↗
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<p className="wt-skip-info">Wird in 3 Sekunden übersprungen...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{!currentRoom.currentVideo && (
|
{!currentRoom.currentVideo && (
|
||||||
<div className="wt-player-placeholder">
|
<div className="wt-player-placeholder">
|
||||||
<div className="wt-placeholder-icon">{'\uD83C\uDFAC'}</div>
|
<div className="wt-placeholder-icon">{'\uD83C\uDFAC'}</div>
|
||||||
|
|
@ -653,7 +706,9 @@ export default function WatchTogetherTab({ data }: { data: any }) {
|
||||||
currentRoom.queue.map((item, i) => (
|
currentRoom.queue.map((item, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
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-info">
|
||||||
<div className="wt-queue-item-title">{item.title || item.url}</div>
|
<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)}
|
onChange={e => setQueueUrl(e.target.value)}
|
||||||
onKeyDown={e => { if (e.key === 'Enter') addToQueue(); }}
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -651,6 +651,57 @@
|
||||||
flex-shrink: 0;
|
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
|
RESPONSIVE
|
||||||
══════════════════════════════════════ */
|
══════════════════════════════════════ */
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue