Fix: Stream-Link Auto-Join Race Condition + Autoplay

- remoteStreamRef speichert MediaStream aus ontrack, damit er nicht
  verloren geht wenn das <video>-Element noch nicht gemountet ist
- useEffect verbindet Stream mit Video sobald beides bereit ist
- Explizites play() mit Mute-Fallback bei Autoplay-Blockierung
  (neuer Tab ohne User-Interaktion, z.B. Discord-Link)
- Debug-Logging aus Notification-Config entfernt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-03-08 20:48:21 +01:00
parent bc6ec39dfd
commit 1c674191c9
2 changed files with 33 additions and 4 deletions

View file

@ -80,6 +80,7 @@ export default function StreamingTab({ data }: { data: any }) {
const remoteVideoRef = useRef<HTMLVideoElement | null>(null);
const peerConnectionsRef = useRef<Map<string, RTCPeerConnection>>(new Map());
const viewerPcRef = useRef<RTCPeerConnection | null>(null);
const remoteStreamRef = useRef<MediaStream | null>(null);
const pendingCandidatesRef = useRef<Map<string, RTCIceCandidateInit[]>>(new Map());
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const reconnectDelayRef = useRef(1000);
@ -161,12 +162,27 @@ export default function StreamingTab({ data }: { data: any }) {
}
}, []);
// ── Attach remote stream to video element (handles autoplay) ──
const attachRemoteStream = useCallback((videoEl: HTMLVideoElement, stream: MediaStream) => {
videoEl.srcObject = stream;
// Explicit play() to handle autoplay restrictions (e.g. fresh tab from Discord link)
const playPromise = videoEl.play();
if (playPromise) {
playPromise.catch(() => {
// Autoplay blocked (no user interaction yet) → mute and retry
videoEl.muted = true;
videoEl.play().catch(() => {});
});
}
}, []);
// ── Viewer cleanup (only viewer PC, keeps broadcaster intact) ──
const cleanupViewer = useCallback(() => {
if (viewerPcRef.current) {
viewerPcRef.current.close();
viewerPcRef.current = null;
}
remoteStreamRef.current = null;
if (remoteVideoRef.current) remoteVideoRef.current.srcObject = null;
// Only clear viewer-related pending candidates (not broadcaster ones)
}, []);
@ -275,7 +291,15 @@ export default function StreamingTab({ data }: { data: any }) {
viewerPcRef.current = pc;
pc.ontrack = (ev) => {
if (remoteVideoRef.current && ev.streams[0]) remoteVideoRef.current.srcObject = ev.streams[0];
const stream = ev.streams[0];
if (!stream) return;
// Store stream in ref so it survives even if video element isn't mounted yet
remoteStreamRef.current = stream;
const videoEl = remoteVideoRef.current;
if (videoEl) {
attachRemoteStream(videoEl, stream);
}
// else: useEffect below will attach once video element is ready
setViewing(prev => prev ? { ...prev, phase: 'connected' } : prev);
};
@ -512,6 +536,14 @@ export default function StreamingTab({ data }: { data: any }) {
};
}, []);
// ── Attach remote stream when video element becomes available ──
// Handles the race condition where ontrack fires before React renders the <video>
useEffect(() => {
if (viewing && remoteStreamRef.current && remoteVideoRef.current && !remoteVideoRef.current.srcObject) {
attachRemoteStream(remoteVideoRef.current, remoteStreamRef.current);
}
}, [viewing, attachRemoteStream]);
// ── Auto-join from URL ?viewStream=... ──
const pendingViewStreamRef = useRef<string | null>(null);
@ -613,7 +645,6 @@ export default function StreamingTab({ data }: { data: any }) {
}, [isAdmin, loadNotifyConfig]);
const toggleChannelEvent = useCallback((channelId: string, channelName: string, guildId: string, guildName: string, event: string) => {
console.log('[Notifications] Toggle:', channelId, channelName, event);
setNotifyConfig(prev => {
const existing = prev.find(c => c.channelId === channelId);
if (existing) {
@ -632,7 +663,6 @@ export default function StreamingTab({ data }: { data: any }) {
}, []);
const saveNotifyConfig = useCallback(async () => {
console.log('[Notifications] Saving config, notifyConfig:', JSON.stringify(notifyConfig));
setConfigSaving(true);
try {
const resp = await fetch('/api/notifications/config', {