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:
parent
bc6ec39dfd
commit
1c674191c9
2 changed files with 33 additions and 4 deletions
|
|
@ -224,7 +224,6 @@ const notificationsPlugin: Plugin = {
|
||||||
|
|
||||||
// Save config
|
// Save config
|
||||||
app.post('/api/notifications/config', requireAdmin, (req, res) => {
|
app.post('/api/notifications/config', requireAdmin, (req, res) => {
|
||||||
console.log(`${NB} Save request body:`, JSON.stringify(req.body));
|
|
||||||
const { channels } = req.body ?? {};
|
const { channels } = req.body ?? {};
|
||||||
if (!Array.isArray(channels)) { res.status(400).json({ error: 'channels array erforderlich' }); return; }
|
if (!Array.isArray(channels)) { res.status(400).json({ error: 'channels array erforderlich' }); return; }
|
||||||
// Validate each channel config
|
// Validate each channel config
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,7 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
const remoteVideoRef = useRef<HTMLVideoElement | null>(null);
|
const remoteVideoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
const peerConnectionsRef = useRef<Map<string, RTCPeerConnection>>(new Map());
|
const peerConnectionsRef = useRef<Map<string, RTCPeerConnection>>(new Map());
|
||||||
const viewerPcRef = useRef<RTCPeerConnection | null>(null);
|
const viewerPcRef = useRef<RTCPeerConnection | null>(null);
|
||||||
|
const remoteStreamRef = useRef<MediaStream | null>(null);
|
||||||
const pendingCandidatesRef = useRef<Map<string, RTCIceCandidateInit[]>>(new Map());
|
const pendingCandidatesRef = useRef<Map<string, RTCIceCandidateInit[]>>(new Map());
|
||||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const reconnectDelayRef = useRef(1000);
|
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) ──
|
// ── Viewer cleanup (only viewer PC, keeps broadcaster intact) ──
|
||||||
const cleanupViewer = useCallback(() => {
|
const cleanupViewer = useCallback(() => {
|
||||||
if (viewerPcRef.current) {
|
if (viewerPcRef.current) {
|
||||||
viewerPcRef.current.close();
|
viewerPcRef.current.close();
|
||||||
viewerPcRef.current = null;
|
viewerPcRef.current = null;
|
||||||
}
|
}
|
||||||
|
remoteStreamRef.current = null;
|
||||||
if (remoteVideoRef.current) remoteVideoRef.current.srcObject = null;
|
if (remoteVideoRef.current) remoteVideoRef.current.srcObject = null;
|
||||||
// Only clear viewer-related pending candidates (not broadcaster ones)
|
// Only clear viewer-related pending candidates (not broadcaster ones)
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -275,7 +291,15 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
viewerPcRef.current = pc;
|
viewerPcRef.current = pc;
|
||||||
|
|
||||||
pc.ontrack = (ev) => {
|
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);
|
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=... ──
|
// ── Auto-join from URL ?viewStream=... ──
|
||||||
const pendingViewStreamRef = useRef<string | null>(null);
|
const pendingViewStreamRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
|
@ -613,7 +645,6 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
}, [isAdmin, loadNotifyConfig]);
|
}, [isAdmin, loadNotifyConfig]);
|
||||||
|
|
||||||
const toggleChannelEvent = useCallback((channelId: string, channelName: string, guildId: string, guildName: string, event: string) => {
|
const toggleChannelEvent = useCallback((channelId: string, channelName: string, guildId: string, guildName: string, event: string) => {
|
||||||
console.log('[Notifications] Toggle:', channelId, channelName, event);
|
|
||||||
setNotifyConfig(prev => {
|
setNotifyConfig(prev => {
|
||||||
const existing = prev.find(c => c.channelId === channelId);
|
const existing = prev.find(c => c.channelId === channelId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
|
@ -632,7 +663,6 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const saveNotifyConfig = useCallback(async () => {
|
const saveNotifyConfig = useCallback(async () => {
|
||||||
console.log('[Notifications] Saving config, notifyConfig:', JSON.stringify(notifyConfig));
|
|
||||||
setConfigSaving(true);
|
setConfigSaving(true);
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/notifications/config', {
|
const resp = await fetch('/api/notifications/config', {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue