2026-03-07 00:39:49 +01:00
|
|
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
|
|
|
import './streaming.css';
|
|
|
|
|
|
|
|
|
|
// ── Types ──
|
|
|
|
|
|
|
|
|
|
interface StreamInfo {
|
|
|
|
|
id: string;
|
|
|
|
|
broadcasterName: string;
|
|
|
|
|
title: string;
|
|
|
|
|
startedAt: string;
|
|
|
|
|
viewerCount: number;
|
2026-03-07 01:00:48 +01:00
|
|
|
hasPassword: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface JoinModal {
|
|
|
|
|
streamId: string;
|
|
|
|
|
streamTitle: string;
|
|
|
|
|
broadcasterName: string;
|
|
|
|
|
password: string;
|
|
|
|
|
error: string | null;
|
2026-03-07 00:39:49 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface ViewState {
|
|
|
|
|
streamId: string;
|
|
|
|
|
phase: 'connecting' | 'connected' | 'error';
|
|
|
|
|
error?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const RTC_CONFIG: RTCConfiguration = {
|
|
|
|
|
iceServers: [
|
|
|
|
|
{ urls: 'stun:stun.l.google.com:19302' },
|
|
|
|
|
{ urls: 'stun:stun1.l.google.com:19302' },
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ── Elapsed time helper ──
|
|
|
|
|
function formatElapsed(startedAt: string): string {
|
|
|
|
|
const diff = Math.max(0, Math.floor((Date.now() - new Date(startedAt).getTime()) / 1000));
|
|
|
|
|
const h = Math.floor(diff / 3600);
|
|
|
|
|
const m = Math.floor((diff % 3600) / 60);
|
|
|
|
|
const s = diff % 60;
|
|
|
|
|
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
|
|
|
|
return `${m}:${String(s).padStart(2, '0')}`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-08 22:50:59 +01:00
|
|
|
// ── Quality Presets ──
|
|
|
|
|
|
|
|
|
|
const QUALITY_PRESETS = [
|
|
|
|
|
{ label: '720p30', width: 1280, height: 720, fps: 30, bitrate: 2_500_000 },
|
|
|
|
|
{ label: '1080p30', width: 1920, height: 1080, fps: 30, bitrate: 5_000_000 },
|
|
|
|
|
{ label: '1080p60', width: 1920, height: 1080, fps: 60, bitrate: 8_000_000 },
|
|
|
|
|
{ label: '1440p60', width: 2560, height: 1440, fps: 60, bitrate: 14_000_000 },
|
|
|
|
|
{ label: '4K60', width: 3840, height: 2160, fps: 60, bitrate: 25_000_000 },
|
|
|
|
|
] as const;
|
|
|
|
|
|
2026-03-07 00:39:49 +01:00
|
|
|
// ── Component ──
|
|
|
|
|
|
|
|
|
|
export default function StreamingTab({ data }: { data: any }) {
|
|
|
|
|
// ── State ──
|
|
|
|
|
const [streams, setStreams] = useState<StreamInfo[]>([]);
|
|
|
|
|
const [userName, setUserName] = useState(() => localStorage.getItem('streaming_name') || '');
|
|
|
|
|
const [streamTitle, setStreamTitle] = useState('Screen Share');
|
2026-03-07 01:00:48 +01:00
|
|
|
const [streamPassword, setStreamPassword] = useState('');
|
2026-03-08 22:50:59 +01:00
|
|
|
const [qualityIdx, setQualityIdx] = useState(2); // Default: 1080p60
|
2026-03-07 00:39:49 +01:00
|
|
|
const [error, setError] = useState<string | null>(null);
|
2026-03-07 01:00:48 +01:00
|
|
|
const [joinModal, setJoinModal] = useState<JoinModal | null>(null);
|
2026-03-07 00:39:49 +01:00
|
|
|
const [myStreamId, setMyStreamId] = useState<string | null>(null);
|
|
|
|
|
const [isBroadcasting, setIsBroadcasting] = useState(false);
|
|
|
|
|
const [starting, setStarting] = useState(false);
|
|
|
|
|
const [viewing, setViewing] = useState<ViewState | null>(null);
|
2026-03-07 01:56:14 +01:00
|
|
|
const [, setTick] = useState(0);
|
|
|
|
|
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
|
|
|
|
const [copiedId, setCopiedId] = useState<string | null>(null);
|
2026-03-07 00:39:49 +01:00
|
|
|
|
2026-03-08 19:29:36 +01:00
|
|
|
// ── Admin / Notification Config ──
|
|
|
|
|
const [showAdmin, setShowAdmin] = useState(false);
|
|
|
|
|
const [isAdmin, setIsAdmin] = useState(false);
|
|
|
|
|
const [adminPwd, setAdminPwd] = useState('');
|
|
|
|
|
const [adminError, setAdminError] = useState('');
|
|
|
|
|
const [availableChannels, setAvailableChannels] = useState<Array<{ channelId: string; channelName: string; guildId: string; guildName: string }>>([]);
|
|
|
|
|
const [notifyConfig, setNotifyConfig] = useState<Array<{ channelId: string; channelName: string; guildId: string; guildName: string; events: string[] }>>([]);
|
|
|
|
|
const [configLoading, setConfigLoading] = useState(false);
|
|
|
|
|
const [configSaving, setConfigSaving] = useState(false);
|
|
|
|
|
const [notifyStatus, setNotifyStatus] = useState<{ online: boolean; botTag: string | null }>({ online: false, botTag: null });
|
|
|
|
|
|
2026-03-07 00:39:49 +01:00
|
|
|
// ── Refs ──
|
|
|
|
|
const wsRef = useRef<WebSocket | null>(null);
|
|
|
|
|
const clientIdRef = useRef<string>('');
|
|
|
|
|
const localStreamRef = useRef<MediaStream | null>(null);
|
|
|
|
|
const localVideoRef = useRef<HTMLVideoElement | null>(null);
|
|
|
|
|
const remoteVideoRef = useRef<HTMLVideoElement | null>(null);
|
|
|
|
|
const peerConnectionsRef = useRef<Map<string, RTCPeerConnection>>(new Map());
|
|
|
|
|
const viewerPcRef = useRef<RTCPeerConnection | null>(null);
|
2026-03-08 20:48:21 +01:00
|
|
|
const remoteStreamRef = useRef<MediaStream | null>(null);
|
2026-03-07 01:16:09 +01:00
|
|
|
const pendingCandidatesRef = useRef<Map<string, RTCIceCandidateInit[]>>(new Map());
|
2026-03-07 00:39:49 +01:00
|
|
|
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
|
const reconnectDelayRef = useRef(1000);
|
|
|
|
|
|
2026-03-07 01:56:14 +01:00
|
|
|
// Refs that mirror state (avoid stale closures in WS handler)
|
2026-03-07 00:51:09 +01:00
|
|
|
const isBroadcastingRef = useRef(false);
|
|
|
|
|
const viewingRef = useRef<ViewState | null>(null);
|
2026-03-08 22:50:59 +01:00
|
|
|
const qualityRef = useRef<typeof QUALITY_PRESETS[number]>(QUALITY_PRESETS[2]);
|
2026-03-07 00:51:09 +01:00
|
|
|
useEffect(() => { isBroadcastingRef.current = isBroadcasting; }, [isBroadcasting]);
|
|
|
|
|
useEffect(() => { viewingRef.current = viewing; }, [viewing]);
|
2026-03-08 22:50:59 +01:00
|
|
|
useEffect(() => { qualityRef.current = QUALITY_PRESETS[qualityIdx]; }, [qualityIdx]);
|
2026-03-07 00:51:09 +01:00
|
|
|
|
2026-03-07 14:14:51 +01:00
|
|
|
// Notify Electron about streaming status for close-warning
|
|
|
|
|
useEffect(() => {
|
2026-03-07 14:47:38 +01:00
|
|
|
(window as any).electronAPI?.setStreaming?.(isBroadcasting || viewing !== null);
|
2026-03-07 14:14:51 +01:00
|
|
|
}, [isBroadcasting, viewing]);
|
|
|
|
|
|
2026-03-07 00:39:49 +01:00
|
|
|
// ── Elapsed time ticker ──
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const hasActive = streams.length > 0 || isBroadcasting;
|
|
|
|
|
if (!hasActive) return;
|
|
|
|
|
const iv = setInterval(() => setTick(t => t + 1), 1000);
|
|
|
|
|
return () => clearInterval(iv);
|
|
|
|
|
}, [streams.length, isBroadcasting]);
|
|
|
|
|
|
|
|
|
|
// ── SSE data → update stream list ──
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (data?.streams) {
|
|
|
|
|
setStreams(data.streams);
|
|
|
|
|
}
|
|
|
|
|
}, [data]);
|
|
|
|
|
|
|
|
|
|
// ── Save name to localStorage ──
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (userName) localStorage.setItem('streaming_name', userName);
|
|
|
|
|
}, [userName]);
|
|
|
|
|
|
2026-03-07 01:56:14 +01:00
|
|
|
// ── Close tile menu on outside click ──
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!openMenu) return;
|
|
|
|
|
const handler = () => setOpenMenu(null);
|
|
|
|
|
document.addEventListener('click', handler);
|
|
|
|
|
return () => document.removeEventListener('click', handler);
|
|
|
|
|
}, [openMenu]);
|
|
|
|
|
|
2026-03-08 19:29:36 +01:00
|
|
|
// Check admin status on mount
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
fetch('/api/notifications/admin/status', { credentials: 'include' })
|
|
|
|
|
.then(r => r.json())
|
|
|
|
|
.then(d => setIsAdmin(d.admin === true))
|
|
|
|
|
.catch(() => {});
|
|
|
|
|
fetch('/api/notifications/status')
|
|
|
|
|
.then(r => r.json())
|
|
|
|
|
.then(d => setNotifyStatus(d))
|
|
|
|
|
.catch(() => {});
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-03-07 00:51:09 +01:00
|
|
|
// ── Send via WS ──
|
|
|
|
|
const wsSend = useCallback((d: Record<string, any>) => {
|
|
|
|
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
|
|
|
wsRef.current.send(JSON.stringify(d));
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
2026-03-07 00:39:49 +01:00
|
|
|
|
2026-03-07 01:56:14 +01:00
|
|
|
// ── ICE candidate queuing ──
|
2026-03-07 01:16:09 +01:00
|
|
|
const addOrQueueCandidate = useCallback((pc: RTCPeerConnection, peerId: string, candidate: RTCIceCandidateInit) => {
|
|
|
|
|
if (pc.remoteDescription) {
|
|
|
|
|
pc.addIceCandidate(new RTCIceCandidate(candidate)).catch(() => {});
|
|
|
|
|
} else {
|
|
|
|
|
let queue = pendingCandidatesRef.current.get(peerId);
|
2026-03-07 01:56:14 +01:00
|
|
|
if (!queue) { queue = []; pendingCandidatesRef.current.set(peerId, queue); }
|
2026-03-07 01:16:09 +01:00
|
|
|
queue.push(candidate);
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const flushCandidates = useCallback((pc: RTCPeerConnection, peerId: string) => {
|
|
|
|
|
const queue = pendingCandidatesRef.current.get(peerId);
|
|
|
|
|
if (queue) {
|
2026-03-07 01:56:14 +01:00
|
|
|
for (const c of queue) pc.addIceCandidate(new RTCIceCandidate(c)).catch(() => {});
|
2026-03-07 01:16:09 +01:00
|
|
|
pendingCandidatesRef.current.delete(peerId);
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-03-08 20:48:21 +01:00
|
|
|
// ── 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(() => {});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-03-07 01:56:14 +01:00
|
|
|
// ── Viewer cleanup (only viewer PC, keeps broadcaster intact) ──
|
2026-03-07 00:51:09 +01:00
|
|
|
const cleanupViewer = useCallback(() => {
|
|
|
|
|
if (viewerPcRef.current) {
|
|
|
|
|
viewerPcRef.current.close();
|
|
|
|
|
viewerPcRef.current = null;
|
|
|
|
|
}
|
2026-03-08 20:48:21 +01:00
|
|
|
remoteStreamRef.current = null;
|
2026-03-07 00:51:09 +01:00
|
|
|
if (remoteVideoRef.current) remoteVideoRef.current.srcObject = null;
|
2026-03-07 01:56:14 +01:00
|
|
|
// Only clear viewer-related pending candidates (not broadcaster ones)
|
2026-03-07 00:51:09 +01:00
|
|
|
}, []);
|
2026-03-07 00:39:49 +01:00
|
|
|
|
2026-03-07 00:51:09 +01:00
|
|
|
// ── WS message handler (uses refs, never stale) ──
|
|
|
|
|
const handleWsMessageRef = useRef<(msg: any) => void>(() => {});
|
|
|
|
|
handleWsMessageRef.current = (msg: any) => {
|
2026-03-07 00:39:49 +01:00
|
|
|
switch (msg.type) {
|
|
|
|
|
case 'welcome':
|
|
|
|
|
clientIdRef.current = msg.clientId;
|
|
|
|
|
if (msg.streams) setStreams(msg.streams);
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case 'broadcast_started':
|
|
|
|
|
setMyStreamId(msg.streamId);
|
|
|
|
|
setIsBroadcasting(true);
|
2026-03-07 01:56:14 +01:00
|
|
|
isBroadcastingRef.current = true;
|
2026-03-07 00:39:49 +01:00
|
|
|
setStarting(false);
|
2026-03-08 00:16:42 +01:00
|
|
|
if ((window as any).electronAPI?.showNotification) {
|
|
|
|
|
(window as any).electronAPI.showNotification('Stream gestartet', 'Dein Stream ist jetzt live!');
|
|
|
|
|
}
|
2026-03-07 00:39:49 +01:00
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case 'stream_available':
|
2026-03-07 15:05:41 +01:00
|
|
|
setStreams(prev => {
|
|
|
|
|
if (prev.some(s => s.id === msg.streamId)) return prev;
|
|
|
|
|
return [...prev, {
|
|
|
|
|
id: msg.streamId,
|
|
|
|
|
broadcasterName: msg.broadcasterName,
|
|
|
|
|
title: msg.title,
|
|
|
|
|
startedAt: new Date().toISOString(),
|
|
|
|
|
viewerCount: 0,
|
2026-03-08 00:16:42 +01:00
|
|
|
hasPassword: !!msg.hasPassword,
|
2026-03-07 15:05:41 +01:00
|
|
|
}];
|
|
|
|
|
});
|
|
|
|
|
// Toast notification for new stream
|
2026-03-08 00:16:42 +01:00
|
|
|
const notifBody = `${msg.broadcasterName} streamt: ${msg.title}`;
|
|
|
|
|
if ((window as any).electronAPI?.showNotification) {
|
|
|
|
|
(window as any).electronAPI.showNotification('Neuer Stream', notifBody);
|
|
|
|
|
} else if (Notification.permission === 'granted') {
|
|
|
|
|
new Notification('Neuer Stream', { body: notifBody, icon: '/assets/icon.png' });
|
2026-03-07 15:05:41 +01:00
|
|
|
}
|
2026-03-07 00:39:49 +01:00
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case 'stream_ended':
|
2026-03-07 15:05:41 +01:00
|
|
|
setStreams(prev => prev.filter(s => s.id !== msg.streamId));
|
2026-03-07 00:51:09 +01:00
|
|
|
if (viewingRef.current?.streamId === msg.streamId) {
|
2026-03-07 00:39:49 +01:00
|
|
|
cleanupViewer();
|
|
|
|
|
setViewing(null);
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
// ── Broadcaster: viewer joined → create offer ──
|
|
|
|
|
case 'viewer_joined': {
|
|
|
|
|
const viewerId = msg.viewerId;
|
2026-03-07 01:16:09 +01:00
|
|
|
const existingPc = peerConnectionsRef.current.get(viewerId);
|
|
|
|
|
if (existingPc) {
|
|
|
|
|
existingPc.close();
|
|
|
|
|
peerConnectionsRef.current.delete(viewerId);
|
|
|
|
|
}
|
|
|
|
|
pendingCandidatesRef.current.delete(viewerId);
|
|
|
|
|
|
2026-03-07 00:39:49 +01:00
|
|
|
const pc = new RTCPeerConnection(RTC_CONFIG);
|
|
|
|
|
peerConnectionsRef.current.set(viewerId, pc);
|
|
|
|
|
|
|
|
|
|
const stream = localStreamRef.current;
|
|
|
|
|
if (stream) {
|
2026-03-07 01:56:14 +01:00
|
|
|
for (const track of stream.getTracks()) pc.addTrack(track, stream);
|
2026-03-07 00:39:49 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pc.onicecandidate = (ev) => {
|
2026-03-07 01:56:14 +01:00
|
|
|
if (ev.candidate) wsSend({ type: 'ice_candidate', targetId: viewerId, candidate: ev.candidate.toJSON() });
|
2026-03-07 00:39:49 +01:00
|
|
|
};
|
|
|
|
|
|
2026-03-08 22:50:59 +01:00
|
|
|
// Apply quality preset to WebRTC encoding
|
2026-03-07 01:34:55 +01:00
|
|
|
const videoSender = pc.getSenders().find(s => s.track?.kind === 'video');
|
|
|
|
|
if (videoSender) {
|
|
|
|
|
const params = videoSender.getParameters();
|
2026-03-07 01:56:14 +01:00
|
|
|
if (!params.encodings || params.encodings.length === 0) params.encodings = [{}];
|
2026-03-08 22:50:59 +01:00
|
|
|
params.encodings[0].maxFramerate = qualityRef.current.fps;
|
|
|
|
|
params.encodings[0].maxBitrate = qualityRef.current.bitrate;
|
2026-03-07 01:34:55 +01:00
|
|
|
videoSender.setParameters(params).catch(() => {});
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-07 00:39:49 +01:00
|
|
|
pc.createOffer()
|
|
|
|
|
.then(offer => pc.setLocalDescription(offer))
|
2026-03-07 01:56:14 +01:00
|
|
|
.then(() => wsSend({ type: 'offer', targetId: viewerId, sdp: pc.localDescription }))
|
2026-03-07 00:39:49 +01:00
|
|
|
.catch(console.error);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case 'viewer_left': {
|
|
|
|
|
const pc = peerConnectionsRef.current.get(msg.viewerId);
|
2026-03-07 01:56:14 +01:00
|
|
|
if (pc) { pc.close(); peerConnectionsRef.current.delete(msg.viewerId); }
|
2026-03-07 01:16:09 +01:00
|
|
|
pendingCandidatesRef.current.delete(msg.viewerId);
|
2026-03-07 00:39:49 +01:00
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Viewer: received offer from broadcaster ──
|
|
|
|
|
case 'offer': {
|
2026-03-07 01:16:09 +01:00
|
|
|
const broadcasterId = msg.fromId;
|
2026-03-07 01:56:14 +01:00
|
|
|
if (viewerPcRef.current) { viewerPcRef.current.close(); viewerPcRef.current = null; }
|
2026-03-07 01:16:09 +01:00
|
|
|
pendingCandidatesRef.current.delete(broadcasterId);
|
|
|
|
|
|
2026-03-07 00:39:49 +01:00
|
|
|
const pc = new RTCPeerConnection(RTC_CONFIG);
|
|
|
|
|
viewerPcRef.current = pc;
|
|
|
|
|
|
|
|
|
|
pc.ontrack = (ev) => {
|
2026-03-08 20:48:21 +01:00
|
|
|
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
|
2026-03-07 00:39:49 +01:00
|
|
|
setViewing(prev => prev ? { ...prev, phase: 'connected' } : prev);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
pc.onicecandidate = (ev) => {
|
2026-03-07 01:56:14 +01:00
|
|
|
if (ev.candidate) wsSend({ type: 'ice_candidate', targetId: broadcasterId, candidate: ev.candidate.toJSON() });
|
2026-03-07 00:39:49 +01:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
pc.oniceconnectionstatechange = () => {
|
|
|
|
|
if (pc.iceConnectionState === 'failed' || pc.iceConnectionState === 'disconnected') {
|
|
|
|
|
setViewing(prev => prev ? { ...prev, phase: 'error', error: 'Verbindung verloren' } : prev);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
pc.setRemoteDescription(new RTCSessionDescription(msg.sdp))
|
2026-03-07 01:56:14 +01:00
|
|
|
.then(() => { flushCandidates(pc, broadcasterId); return pc.createAnswer(); })
|
2026-03-07 00:39:49 +01:00
|
|
|
.then(answer => pc.setLocalDescription(answer))
|
2026-03-07 01:56:14 +01:00
|
|
|
.then(() => wsSend({ type: 'answer', targetId: broadcasterId, sdp: pc.localDescription }))
|
2026-03-07 00:39:49 +01:00
|
|
|
.catch(console.error);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case 'answer': {
|
|
|
|
|
const pc = peerConnectionsRef.current.get(msg.fromId);
|
|
|
|
|
if (pc) {
|
2026-03-07 01:16:09 +01:00
|
|
|
pc.setRemoteDescription(new RTCSessionDescription(msg.sdp))
|
|
|
|
|
.then(() => flushCandidates(pc, msg.fromId))
|
|
|
|
|
.catch(console.error);
|
2026-03-07 00:39:49 +01:00
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-07 01:56:14 +01:00
|
|
|
// ── ICE: try broadcaster map first, then viewer PC (supports dual role) ──
|
2026-03-07 00:39:49 +01:00
|
|
|
case 'ice_candidate': {
|
2026-03-07 01:16:09 +01:00
|
|
|
if (!msg.candidate) break;
|
2026-03-07 01:56:14 +01:00
|
|
|
const broadcasterPc = peerConnectionsRef.current.get(msg.fromId);
|
|
|
|
|
if (broadcasterPc) {
|
|
|
|
|
addOrQueueCandidate(broadcasterPc, msg.fromId, msg.candidate);
|
|
|
|
|
} else if (viewerPcRef.current) {
|
|
|
|
|
addOrQueueCandidate(viewerPcRef.current, msg.fromId, msg.candidate);
|
2026-03-07 00:39:49 +01:00
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case 'error':
|
2026-03-07 01:00:48 +01:00
|
|
|
if (msg.code === 'WRONG_PASSWORD') {
|
|
|
|
|
setJoinModal(prev => prev ? { ...prev, error: msg.message } : prev);
|
|
|
|
|
} else {
|
|
|
|
|
setError(msg.message);
|
|
|
|
|
}
|
2026-03-07 00:39:49 +01:00
|
|
|
setStarting(false);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-07 01:56:14 +01:00
|
|
|
// ── WebSocket connect ──
|
2026-03-07 00:51:09 +01:00
|
|
|
const connectWs = useCallback(() => {
|
|
|
|
|
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) return;
|
|
|
|
|
|
|
|
|
|
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
|
|
|
const ws = new WebSocket(`${proto}://${location.host}/ws/streaming`);
|
|
|
|
|
wsRef.current = ws;
|
|
|
|
|
|
2026-03-07 01:56:14 +01:00
|
|
|
ws.onopen = () => { reconnectDelayRef.current = 1000; };
|
2026-03-07 00:51:09 +01:00
|
|
|
|
|
|
|
|
ws.onmessage = (ev) => {
|
|
|
|
|
let msg: any;
|
|
|
|
|
try { msg = JSON.parse(ev.data); } catch { return; }
|
|
|
|
|
handleWsMessageRef.current(msg);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
ws.onclose = () => {
|
|
|
|
|
wsRef.current = null;
|
2026-03-07 15:05:41 +01:00
|
|
|
// Always reconnect to keep stream list in sync
|
|
|
|
|
reconnectTimerRef.current = setTimeout(() => {
|
|
|
|
|
reconnectDelayRef.current = Math.min(reconnectDelayRef.current * 2, 10000);
|
|
|
|
|
connectWs();
|
|
|
|
|
}, reconnectDelayRef.current);
|
2026-03-07 00:51:09 +01:00
|
|
|
};
|
|
|
|
|
|
2026-03-07 01:56:14 +01:00
|
|
|
ws.onerror = () => { ws.close(); };
|
2026-03-07 00:51:09 +01:00
|
|
|
}, []);
|
|
|
|
|
|
2026-03-07 15:05:41 +01:00
|
|
|
// ── Connect WS on mount for live stream updates ──
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
connectWs();
|
|
|
|
|
return () => {
|
|
|
|
|
if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
|
|
|
|
|
};
|
|
|
|
|
}, [connectWs]);
|
|
|
|
|
|
2026-03-07 00:39:49 +01:00
|
|
|
// ── Start broadcasting ──
|
|
|
|
|
const startBroadcast = useCallback(async () => {
|
|
|
|
|
if (!userName.trim()) { setError('Bitte gib einen Namen ein.'); return; }
|
|
|
|
|
if (!navigator.mediaDevices?.getDisplayMedia) {
|
|
|
|
|
setError('Dein Browser unterstützt keine Bildschirmfreigabe.');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setError(null);
|
|
|
|
|
setStarting(true);
|
|
|
|
|
|
|
|
|
|
try {
|
2026-03-08 22:50:59 +01:00
|
|
|
const q = qualityRef.current;
|
2026-03-07 00:39:49 +01:00
|
|
|
const stream = await navigator.mediaDevices.getDisplayMedia({
|
2026-03-08 22:50:59 +01:00
|
|
|
video: { frameRate: { ideal: q.fps }, width: { ideal: q.width }, height: { ideal: q.height } },
|
2026-03-07 00:39:49 +01:00
|
|
|
audio: true,
|
|
|
|
|
});
|
|
|
|
|
localStreamRef.current = stream;
|
2026-03-07 01:56:14 +01:00
|
|
|
if (localVideoRef.current) localVideoRef.current.srcObject = stream;
|
2026-03-07 00:39:49 +01:00
|
|
|
|
2026-03-07 01:56:14 +01:00
|
|
|
stream.getVideoTracks()[0]?.addEventListener('ended', () => { stopBroadcast(); });
|
2026-03-07 00:39:49 +01:00
|
|
|
|
|
|
|
|
connectWs();
|
|
|
|
|
const waitForWs = () => {
|
|
|
|
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
2026-03-08 00:16:42 +01:00
|
|
|
wsSend({ type: 'start_broadcast', name: userName.trim(), title: streamTitle.trim() || 'Screen Share', password: streamPassword.trim() || undefined });
|
2026-03-07 01:56:14 +01:00
|
|
|
} else { setTimeout(waitForWs, 100); }
|
2026-03-07 00:39:49 +01:00
|
|
|
};
|
|
|
|
|
waitForWs();
|
|
|
|
|
} catch (e: any) {
|
|
|
|
|
setStarting(false);
|
2026-03-07 01:56:14 +01:00
|
|
|
if (e.name === 'NotAllowedError') setError('Bildschirmfreigabe wurde abgelehnt.');
|
|
|
|
|
else setError(`Fehler: ${e.message}`);
|
2026-03-07 00:39:49 +01:00
|
|
|
}
|
2026-03-07 01:00:48 +01:00
|
|
|
}, [userName, streamTitle, streamPassword, connectWs, wsSend]);
|
2026-03-07 00:39:49 +01:00
|
|
|
|
2026-03-07 01:56:14 +01:00
|
|
|
// ── Stop broadcasting (keeps viewer connection intact) ──
|
2026-03-07 00:39:49 +01:00
|
|
|
const stopBroadcast = useCallback(() => {
|
|
|
|
|
wsSend({ type: 'stop_broadcast' });
|
|
|
|
|
localStreamRef.current?.getTracks().forEach(t => t.stop());
|
|
|
|
|
localStreamRef.current = null;
|
|
|
|
|
if (localVideoRef.current) localVideoRef.current.srcObject = null;
|
|
|
|
|
|
|
|
|
|
for (const pc of peerConnectionsRef.current.values()) pc.close();
|
|
|
|
|
peerConnectionsRef.current.clear();
|
|
|
|
|
|
|
|
|
|
setIsBroadcasting(false);
|
2026-03-07 00:51:09 +01:00
|
|
|
isBroadcastingRef.current = false;
|
2026-03-07 00:39:49 +01:00
|
|
|
setMyStreamId(null);
|
2026-03-07 01:00:48 +01:00
|
|
|
setStreamPassword('');
|
2026-03-07 00:51:09 +01:00
|
|
|
}, [wsSend]);
|
2026-03-07 00:39:49 +01:00
|
|
|
|
2026-03-07 01:56:14 +01:00
|
|
|
// ── Join as viewer ──
|
2026-03-08 00:16:42 +01:00
|
|
|
const joinStreamDirectly = useCallback((streamId: string) => {
|
|
|
|
|
setError(null);
|
|
|
|
|
setViewing({ streamId, phase: 'connecting' });
|
|
|
|
|
connectWs();
|
|
|
|
|
const waitForWs = () => {
|
|
|
|
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
|
|
|
wsSend({ type: 'join_viewer', name: userName.trim() || 'Viewer', streamId });
|
|
|
|
|
} else { setTimeout(waitForWs, 100); }
|
|
|
|
|
};
|
|
|
|
|
waitForWs();
|
|
|
|
|
}, [userName, connectWs, wsSend]);
|
|
|
|
|
|
2026-03-07 01:00:48 +01:00
|
|
|
const openJoinModal = useCallback((s: StreamInfo) => {
|
2026-03-08 00:16:42 +01:00
|
|
|
if (s.hasPassword) {
|
|
|
|
|
setJoinModal({ streamId: s.id, streamTitle: s.title, broadcasterName: s.broadcasterName, password: '', error: null });
|
|
|
|
|
} else {
|
|
|
|
|
joinStreamDirectly(s.id);
|
|
|
|
|
}
|
|
|
|
|
}, [joinStreamDirectly]);
|
2026-03-07 01:00:48 +01:00
|
|
|
|
|
|
|
|
const submitJoinModal = useCallback(() => {
|
|
|
|
|
if (!joinModal) return;
|
|
|
|
|
if (!joinModal.password.trim()) {
|
|
|
|
|
setJoinModal(prev => prev ? { ...prev, error: 'Passwort eingeben.' } : prev);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const { streamId, password } = joinModal;
|
|
|
|
|
setJoinModal(null);
|
2026-03-07 00:39:49 +01:00
|
|
|
setError(null);
|
|
|
|
|
setViewing({ streamId, phase: 'connecting' });
|
|
|
|
|
connectWs();
|
|
|
|
|
|
|
|
|
|
const waitForWs = () => {
|
|
|
|
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
2026-03-07 01:00:48 +01:00
|
|
|
wsSend({ type: 'join_viewer', name: userName.trim() || 'Viewer', streamId, password: password.trim() });
|
2026-03-07 01:56:14 +01:00
|
|
|
} else { setTimeout(waitForWs, 100); }
|
2026-03-07 00:39:49 +01:00
|
|
|
};
|
|
|
|
|
waitForWs();
|
2026-03-07 01:00:48 +01:00
|
|
|
}, [joinModal, userName, connectWs, wsSend]);
|
2026-03-07 00:39:49 +01:00
|
|
|
|
|
|
|
|
// ── Leave viewer ──
|
|
|
|
|
const leaveViewing = useCallback(() => {
|
|
|
|
|
wsSend({ type: 'leave_viewer' });
|
|
|
|
|
cleanupViewer();
|
|
|
|
|
setViewing(null);
|
2026-03-07 00:51:09 +01:00
|
|
|
}, [cleanupViewer, wsSend]);
|
2026-03-07 00:39:49 +01:00
|
|
|
|
2026-03-07 01:56:14 +01:00
|
|
|
// ── Warn before leaving + beacon cleanup ──
|
2026-03-07 01:37:21 +01:00
|
|
|
useEffect(() => {
|
2026-03-07 01:56:14 +01:00
|
|
|
const beforeUnload = (e: BeforeUnloadEvent) => {
|
|
|
|
|
if (isBroadcastingRef.current || viewingRef.current) e.preventDefault();
|
|
|
|
|
};
|
|
|
|
|
const pageHide = () => {
|
|
|
|
|
// Guaranteed delivery via sendBeacon
|
|
|
|
|
if (clientIdRef.current) {
|
|
|
|
|
navigator.sendBeacon('/api/streaming/disconnect', JSON.stringify({ clientId: clientIdRef.current }));
|
2026-03-07 01:37:21 +01:00
|
|
|
}
|
|
|
|
|
};
|
2026-03-07 01:56:14 +01:00
|
|
|
window.addEventListener('beforeunload', beforeUnload);
|
|
|
|
|
window.addEventListener('pagehide', pageHide);
|
|
|
|
|
return () => {
|
|
|
|
|
window.removeEventListener('beforeunload', beforeUnload);
|
|
|
|
|
window.removeEventListener('pagehide', pageHide);
|
|
|
|
|
};
|
2026-03-07 01:37:21 +01:00
|
|
|
}, []);
|
|
|
|
|
|
2026-03-07 01:56:14 +01:00
|
|
|
// ── Fullscreen toggle ──
|
2026-03-07 01:37:21 +01:00
|
|
|
const viewerContainerRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
|
|
|
|
|
|
|
|
const toggleFullscreen = useCallback(() => {
|
|
|
|
|
const el = viewerContainerRef.current;
|
|
|
|
|
if (!el) return;
|
2026-03-07 01:56:14 +01:00
|
|
|
if (!document.fullscreenElement) el.requestFullscreen().catch(() => {});
|
|
|
|
|
else document.exitFullscreen().catch(() => {});
|
2026-03-07 01:37:21 +01:00
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const handler = () => setIsFullscreen(!!document.fullscreenElement);
|
|
|
|
|
document.addEventListener('fullscreenchange', handler);
|
|
|
|
|
return () => document.removeEventListener('fullscreenchange', handler);
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-03-07 00:39:49 +01:00
|
|
|
// ── Cleanup on unmount ──
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
return () => {
|
|
|
|
|
localStreamRef.current?.getTracks().forEach(t => t.stop());
|
|
|
|
|
for (const pc of peerConnectionsRef.current.values()) pc.close();
|
|
|
|
|
if (viewerPcRef.current) viewerPcRef.current.close();
|
|
|
|
|
if (wsRef.current) wsRef.current.close();
|
|
|
|
|
if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
|
|
|
|
|
};
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-03-08 20:48:21 +01:00
|
|
|
// ── 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]);
|
|
|
|
|
|
2026-03-07 01:56:14 +01:00
|
|
|
// ── Auto-join from URL ?viewStream=... ──
|
2026-03-07 02:07:49 +01:00
|
|
|
const pendingViewStreamRef = useRef<string | null>(null);
|
|
|
|
|
|
|
|
|
|
// On mount: grab the streamId from URL and clear it
|
2026-03-07 01:56:14 +01:00
|
|
|
useEffect(() => {
|
|
|
|
|
const params = new URLSearchParams(location.search);
|
|
|
|
|
const streamId = params.get('viewStream');
|
2026-03-07 02:07:49 +01:00
|
|
|
if (streamId) {
|
|
|
|
|
pendingViewStreamRef.current = streamId;
|
|
|
|
|
const url = new URL(location.href);
|
|
|
|
|
url.searchParams.delete('viewStream');
|
|
|
|
|
window.history.replaceState({}, '', url.toString());
|
|
|
|
|
}
|
2026-03-07 01:56:14 +01:00
|
|
|
}, []);
|
|
|
|
|
|
2026-03-07 02:07:49 +01:00
|
|
|
// When streams update, check if we have a pending auto-join
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const pending = pendingViewStreamRef.current;
|
|
|
|
|
if (!pending || streams.length === 0) return;
|
|
|
|
|
const s = streams.find(st => st.id === pending);
|
|
|
|
|
if (s) {
|
|
|
|
|
pendingViewStreamRef.current = null;
|
|
|
|
|
openJoinModal(s);
|
|
|
|
|
}
|
|
|
|
|
}, [streams, openJoinModal]);
|
|
|
|
|
|
2026-03-07 01:56:14 +01:00
|
|
|
// ── Helpers for 3-dot menu ──
|
|
|
|
|
const buildStreamLink = useCallback((streamId: string) => {
|
|
|
|
|
const url = new URL(location.href);
|
|
|
|
|
url.searchParams.set('viewStream', streamId);
|
|
|
|
|
// Make sure we're on the streaming tab
|
|
|
|
|
url.hash = '';
|
|
|
|
|
return url.toString();
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const copyStreamLink = useCallback((streamId: string) => {
|
|
|
|
|
navigator.clipboard.writeText(buildStreamLink(streamId)).then(() => {
|
|
|
|
|
setCopiedId(streamId);
|
|
|
|
|
setTimeout(() => setCopiedId(null), 2000);
|
|
|
|
|
}).catch(() => {});
|
|
|
|
|
}, [buildStreamLink]);
|
|
|
|
|
|
|
|
|
|
const openInNewWindow = useCallback((streamId: string) => {
|
|
|
|
|
window.open(buildStreamLink(streamId), '_blank', 'noopener');
|
|
|
|
|
setOpenMenu(null);
|
|
|
|
|
}, [buildStreamLink]);
|
|
|
|
|
|
2026-03-08 19:29:36 +01:00
|
|
|
// ── Admin functions ──
|
|
|
|
|
const adminLogin = useCallback(async () => {
|
|
|
|
|
setAdminError('');
|
|
|
|
|
try {
|
|
|
|
|
const resp = await fetch('/api/notifications/admin/login', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify({ password: adminPwd }),
|
|
|
|
|
credentials: 'include',
|
|
|
|
|
});
|
|
|
|
|
if (resp.ok) {
|
|
|
|
|
setIsAdmin(true);
|
|
|
|
|
setAdminPwd('');
|
|
|
|
|
loadNotifyConfig();
|
|
|
|
|
} else {
|
|
|
|
|
const d = await resp.json();
|
|
|
|
|
setAdminError(d.error || 'Fehler');
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
setAdminError('Verbindung fehlgeschlagen');
|
|
|
|
|
}
|
|
|
|
|
}, [adminPwd]);
|
|
|
|
|
|
|
|
|
|
const adminLogout = useCallback(async () => {
|
|
|
|
|
await fetch('/api/notifications/admin/logout', { method: 'POST', credentials: 'include' });
|
|
|
|
|
setIsAdmin(false);
|
|
|
|
|
setShowAdmin(false);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const loadNotifyConfig = useCallback(async () => {
|
|
|
|
|
setConfigLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
const [chResp, cfgResp] = await Promise.all([
|
|
|
|
|
fetch('/api/notifications/channels', { credentials: 'include' }),
|
|
|
|
|
fetch('/api/notifications/config', { credentials: 'include' }),
|
|
|
|
|
]);
|
|
|
|
|
if (chResp.ok) {
|
|
|
|
|
const chData = await chResp.json();
|
|
|
|
|
setAvailableChannels(chData.channels || []);
|
|
|
|
|
}
|
|
|
|
|
if (cfgResp.ok) {
|
|
|
|
|
const cfgData = await cfgResp.json();
|
|
|
|
|
setNotifyConfig(cfgData.channels || []);
|
|
|
|
|
}
|
|
|
|
|
} catch { /* silent */ }
|
|
|
|
|
finally { setConfigLoading(false); }
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const openAdmin = useCallback(() => {
|
|
|
|
|
setShowAdmin(true);
|
|
|
|
|
if (isAdmin) loadNotifyConfig();
|
|
|
|
|
}, [isAdmin, loadNotifyConfig]);
|
|
|
|
|
|
|
|
|
|
const toggleChannelEvent = useCallback((channelId: string, channelName: string, guildId: string, guildName: string, event: string) => {
|
|
|
|
|
setNotifyConfig(prev => {
|
|
|
|
|
const existing = prev.find(c => c.channelId === channelId);
|
|
|
|
|
if (existing) {
|
|
|
|
|
const hasEvent = existing.events.includes(event);
|
|
|
|
|
const newEvents = hasEvent
|
|
|
|
|
? existing.events.filter(e => e !== event)
|
|
|
|
|
: [...existing.events, event];
|
|
|
|
|
if (newEvents.length === 0) {
|
|
|
|
|
return prev.filter(c => c.channelId !== channelId);
|
|
|
|
|
}
|
|
|
|
|
return prev.map(c => c.channelId === channelId ? { ...c, events: newEvents } : c);
|
|
|
|
|
} else {
|
|
|
|
|
return [...prev, { channelId, channelName, guildId, guildName, events: [event] }];
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const saveNotifyConfig = useCallback(async () => {
|
|
|
|
|
setConfigSaving(true);
|
|
|
|
|
try {
|
|
|
|
|
const resp = await fetch('/api/notifications/config', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify({ channels: notifyConfig }),
|
|
|
|
|
credentials: 'include',
|
|
|
|
|
});
|
|
|
|
|
if (resp.ok) {
|
|
|
|
|
// brief visual feedback handled by configSaving state
|
|
|
|
|
}
|
|
|
|
|
} catch { /* silent */ }
|
|
|
|
|
finally { setConfigSaving(false); }
|
|
|
|
|
}, [notifyConfig]);
|
|
|
|
|
|
|
|
|
|
const isChannelEventEnabled = useCallback((channelId: string, event: string): boolean => {
|
|
|
|
|
const ch = notifyConfig.find(c => c.channelId === channelId);
|
|
|
|
|
return ch?.events.includes(event) ?? false;
|
|
|
|
|
}, [notifyConfig]);
|
|
|
|
|
|
2026-03-07 00:39:49 +01:00
|
|
|
// ── Render ──
|
|
|
|
|
|
|
|
|
|
// Fullscreen viewer overlay
|
|
|
|
|
if (viewing) {
|
|
|
|
|
const stream = streams.find(s => s.id === viewing.streamId);
|
|
|
|
|
return (
|
2026-03-07 01:37:21 +01:00
|
|
|
<div className="stream-viewer-overlay" ref={viewerContainerRef}>
|
2026-03-07 00:39:49 +01:00
|
|
|
<div className="stream-viewer-header">
|
|
|
|
|
<div className="stream-viewer-header-left">
|
|
|
|
|
<span className="stream-live-badge"><span className="stream-live-dot" /> LIVE</span>
|
|
|
|
|
<div>
|
|
|
|
|
<div className="stream-viewer-title">{stream?.title || 'Stream'}</div>
|
|
|
|
|
<div className="stream-viewer-subtitle">
|
2026-03-07 01:56:14 +01:00
|
|
|
{stream?.broadcasterName || '...'} {stream ? ` · ${stream.viewerCount} Zuschauer` : ''}
|
2026-03-07 00:39:49 +01:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-03-07 01:37:21 +01:00
|
|
|
<div className="stream-viewer-header-right">
|
|
|
|
|
<button className="stream-viewer-fullscreen" onClick={toggleFullscreen} title={isFullscreen ? 'Vollbild verlassen' : 'Vollbild'}>
|
|
|
|
|
{isFullscreen ? '\u2716' : '\u26F6'}
|
|
|
|
|
</button>
|
|
|
|
|
<button className="stream-viewer-close" onClick={leaveViewing}>Verlassen</button>
|
|
|
|
|
</div>
|
2026-03-07 00:39:49 +01:00
|
|
|
</div>
|
|
|
|
|
<div className="stream-viewer-video">
|
|
|
|
|
{viewing.phase === 'connecting' ? (
|
|
|
|
|
<div className="stream-viewer-connecting">
|
|
|
|
|
<div className="stream-viewer-spinner" />
|
|
|
|
|
Verbindung wird hergestellt...
|
|
|
|
|
</div>
|
|
|
|
|
) : viewing.phase === 'error' ? (
|
|
|
|
|
<div className="stream-viewer-connecting">
|
|
|
|
|
{viewing.error || 'Verbindungsfehler'}
|
2026-03-07 01:45:47 +01:00
|
|
|
<button className="stream-btn" onClick={leaveViewing}>Zurück</button>
|
2026-03-07 00:39:49 +01:00
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
<video ref={remoteVideoRef} autoPlay playsInline style={viewing.phase === 'connected' ? {} : { display: 'none' }} />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="stream-container">
|
|
|
|
|
{error && (
|
|
|
|
|
<div className="stream-error">
|
|
|
|
|
{error}
|
|
|
|
|
<button className="stream-error-dismiss" onClick={() => setError(null)}>{'\u00D7'}</button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<div className="stream-topbar">
|
|
|
|
|
<input
|
|
|
|
|
className="stream-input stream-input-name"
|
|
|
|
|
placeholder="Dein Name"
|
|
|
|
|
value={userName}
|
|
|
|
|
onChange={e => setUserName(e.target.value)}
|
|
|
|
|
disabled={isBroadcasting}
|
|
|
|
|
/>
|
|
|
|
|
<input
|
|
|
|
|
className="stream-input stream-input-title"
|
|
|
|
|
placeholder="Stream-Titel"
|
|
|
|
|
value={streamTitle}
|
|
|
|
|
onChange={e => setStreamTitle(e.target.value)}
|
|
|
|
|
disabled={isBroadcasting}
|
|
|
|
|
/>
|
2026-03-07 01:00:48 +01:00
|
|
|
<input
|
|
|
|
|
className="stream-input stream-input-password"
|
|
|
|
|
type="password"
|
2026-03-08 00:16:42 +01:00
|
|
|
placeholder="Passwort (optional)"
|
2026-03-07 01:00:48 +01:00
|
|
|
value={streamPassword}
|
|
|
|
|
onChange={e => setStreamPassword(e.target.value)}
|
|
|
|
|
disabled={isBroadcasting}
|
|
|
|
|
/>
|
2026-03-08 22:50:59 +01:00
|
|
|
<select
|
|
|
|
|
className="stream-select-quality"
|
|
|
|
|
value={qualityIdx}
|
|
|
|
|
onChange={e => setQualityIdx(Number(e.target.value))}
|
|
|
|
|
disabled={isBroadcasting}
|
|
|
|
|
title="Stream-Qualität"
|
|
|
|
|
>
|
|
|
|
|
{QUALITY_PRESETS.map((p, i) => (
|
|
|
|
|
<option key={p.label} value={i}>{p.label}</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
2026-03-07 00:39:49 +01:00
|
|
|
{isBroadcasting ? (
|
|
|
|
|
<button className="stream-btn stream-btn-stop" onClick={stopBroadcast}>
|
|
|
|
|
{'\u23F9'} Stream beenden
|
|
|
|
|
</button>
|
|
|
|
|
) : (
|
|
|
|
|
<button className="stream-btn" onClick={startBroadcast} disabled={starting}>
|
|
|
|
|
{starting ? 'Starte...' : '\u{1F5A5}\uFE0F Stream starten'}
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
2026-03-08 19:29:36 +01:00
|
|
|
<button className="stream-admin-btn" onClick={openAdmin} title="Notification Einstellungen">
|
|
|
|
|
{'\u2699\uFE0F'}
|
|
|
|
|
</button>
|
2026-03-07 00:39:49 +01:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{streams.length === 0 && !isBroadcasting ? (
|
|
|
|
|
<div className="stream-empty">
|
|
|
|
|
<div className="stream-empty-icon">{'\u{1F4FA}'}</div>
|
|
|
|
|
<h3>Keine aktiven Streams</h3>
|
|
|
|
|
<p>Starte einen Stream, um deinen Bildschirm zu teilen.</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="stream-grid">
|
|
|
|
|
{isBroadcasting && (
|
|
|
|
|
<div className="stream-tile own broadcasting">
|
|
|
|
|
<div className="stream-tile-preview">
|
|
|
|
|
<video ref={localVideoRef} autoPlay playsInline muted />
|
|
|
|
|
<span className="stream-live-badge"><span className="stream-live-dot" /> LIVE</span>
|
|
|
|
|
<span className="stream-tile-viewers">
|
|
|
|
|
{'\u{1F465}'} {streams.find(s => s.id === myStreamId)?.viewerCount ?? 0}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="stream-tile-info">
|
|
|
|
|
<div className="stream-tile-meta">
|
|
|
|
|
<div className="stream-tile-name">{userName} (Du)</div>
|
|
|
|
|
<div className="stream-tile-title">{streamTitle}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<span className="stream-tile-time">
|
|
|
|
|
{myStreamId && streams.find(s => s.id === myStreamId)?.startedAt
|
|
|
|
|
? formatElapsed(streams.find(s => s.id === myStreamId)!.startedAt)
|
|
|
|
|
: '0:00'}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{streams
|
|
|
|
|
.filter(s => s.id !== myStreamId)
|
|
|
|
|
.map(s => (
|
2026-03-07 14:57:41 +01:00
|
|
|
<div key={s.id} className="stream-tile" onClick={() => openJoinModal(s)}>
|
2026-03-07 00:39:49 +01:00
|
|
|
<div className="stream-tile-preview">
|
|
|
|
|
<span className="stream-tile-icon">{'\u{1F5A5}\uFE0F'}</span>
|
|
|
|
|
<span className="stream-live-badge"><span className="stream-live-dot" /> LIVE</span>
|
|
|
|
|
<span className="stream-tile-viewers">{'\u{1F465}'} {s.viewerCount}</span>
|
2026-03-07 01:00:48 +01:00
|
|
|
{s.hasPassword && <span className="stream-tile-lock">{'\u{1F512}'}</span>}
|
2026-03-07 00:39:49 +01:00
|
|
|
</div>
|
|
|
|
|
<div className="stream-tile-info">
|
|
|
|
|
<div className="stream-tile-meta">
|
|
|
|
|
<div className="stream-tile-name">{s.broadcasterName}</div>
|
|
|
|
|
<div className="stream-tile-title">{s.title}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<span className="stream-tile-time">{formatElapsed(s.startedAt)}</span>
|
2026-03-07 01:56:14 +01:00
|
|
|
<div className="stream-tile-menu-wrap">
|
|
|
|
|
<button
|
|
|
|
|
className="stream-tile-menu"
|
|
|
|
|
onClick={e => { e.stopPropagation(); setOpenMenu(openMenu === s.id ? null : s.id); }}
|
|
|
|
|
>
|
|
|
|
|
{'\u22EE'}
|
|
|
|
|
</button>
|
|
|
|
|
{openMenu === s.id && (
|
|
|
|
|
<div className="stream-tile-dropdown" onClick={e => e.stopPropagation()}>
|
|
|
|
|
<div className="stream-tile-dropdown-header">
|
|
|
|
|
<div className="stream-tile-dropdown-name">{s.broadcasterName}</div>
|
|
|
|
|
<div className="stream-tile-dropdown-title">{s.title}</div>
|
|
|
|
|
<div className="stream-tile-dropdown-detail">
|
|
|
|
|
{'\u{1F465}'} {s.viewerCount} Zuschauer · {formatElapsed(s.startedAt)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="stream-tile-dropdown-divider" />
|
|
|
|
|
<button className="stream-tile-dropdown-item" onClick={() => openInNewWindow(s.id)}>
|
|
|
|
|
{'\u{1F5D7}'} In neuem Fenster öffnen
|
|
|
|
|
</button>
|
|
|
|
|
<button className="stream-tile-dropdown-item" onClick={() => { copyStreamLink(s.id); setOpenMenu(null); }}>
|
|
|
|
|
{copiedId === s.id ? '\u2705 Kopiert!' : '\u{1F517} Link teilen'}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-03-07 00:39:49 +01:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-03-07 01:00:48 +01:00
|
|
|
|
|
|
|
|
{joinModal && (
|
|
|
|
|
<div className="stream-pw-overlay" onClick={() => setJoinModal(null)}>
|
|
|
|
|
<div className="stream-pw-modal" onClick={e => e.stopPropagation()}>
|
|
|
|
|
<h3>{joinModal.broadcasterName}</h3>
|
|
|
|
|
<p>{joinModal.streamTitle}</p>
|
|
|
|
|
{joinModal.error && <div className="stream-pw-modal-error">{joinModal.error}</div>}
|
|
|
|
|
<input
|
|
|
|
|
className="stream-input"
|
|
|
|
|
type="password"
|
|
|
|
|
placeholder="Stream-Passwort"
|
|
|
|
|
value={joinModal.password}
|
|
|
|
|
onChange={e => setJoinModal(prev => prev ? { ...prev, password: e.target.value, error: null } : prev)}
|
|
|
|
|
onKeyDown={e => { if (e.key === 'Enter') submitJoinModal(); }}
|
|
|
|
|
autoFocus
|
|
|
|
|
/>
|
|
|
|
|
<div className="stream-pw-actions">
|
|
|
|
|
<button className="stream-pw-cancel" onClick={() => setJoinModal(null)}>Abbrechen</button>
|
|
|
|
|
<button className="stream-btn" onClick={submitJoinModal}>Beitreten</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-03-08 19:29:36 +01:00
|
|
|
|
|
|
|
|
{/* ── Notification Admin Modal ── */}
|
|
|
|
|
{showAdmin && (
|
|
|
|
|
<div className="stream-admin-overlay" onClick={() => setShowAdmin(false)}>
|
|
|
|
|
<div className="stream-admin-panel" onClick={e => e.stopPropagation()}>
|
|
|
|
|
<div className="stream-admin-header">
|
|
|
|
|
<h3>{'\uD83D\uDD14'} Benachrichtigungen</h3>
|
|
|
|
|
<button className="stream-admin-close" onClick={() => setShowAdmin(false)}>{'\u2715'}</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{!isAdmin ? (
|
|
|
|
|
<div className="stream-admin-login">
|
|
|
|
|
<p>Admin-Passwort eingeben:</p>
|
|
|
|
|
<div className="stream-admin-login-row">
|
|
|
|
|
<input
|
|
|
|
|
type="password"
|
|
|
|
|
className="stream-input"
|
|
|
|
|
placeholder="Passwort"
|
|
|
|
|
value={adminPwd}
|
|
|
|
|
onChange={e => setAdminPwd(e.target.value)}
|
|
|
|
|
onKeyDown={e => { if (e.key === 'Enter') adminLogin(); }}
|
|
|
|
|
autoFocus
|
|
|
|
|
/>
|
|
|
|
|
<button className="stream-btn" onClick={adminLogin}>Login</button>
|
|
|
|
|
</div>
|
|
|
|
|
{adminError && <p className="stream-admin-error">{adminError}</p>}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="stream-admin-content">
|
|
|
|
|
<div className="stream-admin-toolbar">
|
|
|
|
|
<span className="stream-admin-status">
|
|
|
|
|
{notifyStatus.online
|
|
|
|
|
? <>{'\u2705'} Bot online: <b>{notifyStatus.botTag}</b></>
|
|
|
|
|
: <>{'\u26A0\uFE0F'} Bot offline — <code>DISCORD_TOKEN_NOTIFICATIONS</code> setzen</>}
|
|
|
|
|
</span>
|
|
|
|
|
<button className="stream-admin-logout" onClick={adminLogout}>Logout</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{configLoading ? (
|
|
|
|
|
<div className="stream-admin-loading">Lade Kan{'\u00E4'}le...</div>
|
|
|
|
|
) : availableChannels.length === 0 ? (
|
|
|
|
|
<div className="stream-admin-empty">
|
|
|
|
|
{notifyStatus.online
|
|
|
|
|
? 'Keine Text-Kan\u00E4le gefunden. Bot hat m\u00F6glicherweise keinen Zugriff.'
|
|
|
|
|
: 'Bot ist nicht verbunden. Bitte DISCORD_TOKEN_NOTIFICATIONS konfigurieren.'}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<p className="stream-admin-hint">
|
|
|
|
|
W{'\u00E4'}hle die Kan{'\u00E4'}le, in die Benachrichtigungen gesendet werden sollen:
|
|
|
|
|
</p>
|
|
|
|
|
<div className="stream-admin-channel-list">
|
|
|
|
|
{availableChannels.map(ch => (
|
|
|
|
|
<div key={ch.channelId} className="stream-admin-channel">
|
|
|
|
|
<div className="stream-admin-channel-info">
|
|
|
|
|
<span className="stream-admin-channel-name">#{ch.channelName}</span>
|
|
|
|
|
<span className="stream-admin-channel-guild">{ch.guildName}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="stream-admin-channel-events">
|
|
|
|
|
<label className={`stream-admin-event-toggle${isChannelEventEnabled(ch.channelId, 'stream_start') ? ' active' : ''}`}>
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={isChannelEventEnabled(ch.channelId, 'stream_start')}
|
|
|
|
|
onChange={() => toggleChannelEvent(ch.channelId, ch.channelName, ch.guildId, ch.guildName, 'stream_start')}
|
|
|
|
|
/>
|
|
|
|
|
{'\uD83D\uDD34'} Stream Start
|
|
|
|
|
</label>
|
|
|
|
|
<label className={`stream-admin-event-toggle${isChannelEventEnabled(ch.channelId, 'stream_end') ? ' active' : ''}`}>
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={isChannelEventEnabled(ch.channelId, 'stream_end')}
|
|
|
|
|
onChange={() => toggleChannelEvent(ch.channelId, ch.channelName, ch.guildId, ch.guildName, 'stream_end')}
|
|
|
|
|
/>
|
|
|
|
|
{'\u23F9\uFE0F'} Stream Ende
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="stream-admin-actions">
|
|
|
|
|
<button
|
|
|
|
|
className="stream-btn stream-admin-save"
|
|
|
|
|
onClick={saveNotifyConfig}
|
|
|
|
|
disabled={configSaving}
|
|
|
|
|
>
|
|
|
|
|
{configSaving ? 'Speichern...' : '\uD83D\uDCBE Speichern'}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-03-07 00:39:49 +01:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|