Streaming: Stale-Stream Fix, Broadcast+View gleichzeitig, 3-Punkt-Menü
Server: - Dual-Role: Client kann gleichzeitig broadcasten UND zuschauen (broadcastStreamId + viewingStreamId statt single role) - POST /api/streaming/disconnect Beacon-Endpoint fuer zuverlaessigen Cleanup bei Page-Unload - Heartbeat auf 5s reduziert (schnellere Erkennung) Frontend: - pagehide + sendBeacon: Streams werden sofort aufgeraeumt wenn Browser geschlossen/neugeladen wird - ICE Routing: Broadcaster-Map wird zuerst geprueft, dann Viewer-PC → Broadcast + View im selben Tab moeglich - 3-Punkt-Menü mit Stream-Details, "In neuem Fenster oeffnen" und "Link teilen" (Clipboard) - Auto-Join via ?viewStream=... Query-Parameter (fuer geteilte Links) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
813e017036
commit
470bef62e4
7 changed files with 5091 additions and 5013 deletions
|
|
@ -18,10 +18,12 @@ interface StreamInfo {
|
||||||
interface WsClient {
|
interface WsClient {
|
||||||
id: string;
|
id: string;
|
||||||
ws: WebSocket;
|
ws: WebSocket;
|
||||||
role: 'idle' | 'broadcaster' | 'viewer';
|
|
||||||
name: string;
|
name: string;
|
||||||
streamId?: string; // ID of stream this client broadcasts or views
|
/** Stream ID this client broadcasts (undefined if not broadcasting) */
|
||||||
isAlive: boolean; // heartbeat tracking
|
broadcastStreamId?: string;
|
||||||
|
/** Stream ID this client views (undefined if not viewing) */
|
||||||
|
viewingStreamId?: string;
|
||||||
|
isAlive: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── State ──
|
// ── State ──
|
||||||
|
|
@ -33,8 +35,7 @@ const wsClients = new Map<string, WsClient>();
|
||||||
|
|
||||||
let wss: WebSocketServer | null = null;
|
let wss: WebSocketServer | null = null;
|
||||||
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
|
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
const HEARTBEAT_MS = 10_000; // ping every 10s
|
const HEARTBEAT_MS = 5_000; // ping every 5s (faster detection)
|
||||||
const HEARTBEAT_TIMEOUT = 25_000; // dead after missing ~2 pings
|
|
||||||
|
|
||||||
// ── Helpers ──
|
// ── Helpers ──
|
||||||
|
|
||||||
|
|
@ -61,18 +62,16 @@ function endStream(streamId: string, reason: string): void {
|
||||||
|
|
||||||
// Notify all viewers of this stream
|
// Notify all viewers of this stream
|
||||||
for (const c of wsClients.values()) {
|
for (const c of wsClients.values()) {
|
||||||
if (c.role === 'viewer' && c.streamId === streamId) {
|
if (c.viewingStreamId === streamId) {
|
||||||
sendTo(c, { type: 'stream_ended', streamId, reason });
|
sendTo(c, { type: 'stream_ended', streamId, reason });
|
||||||
c.role = 'idle';
|
c.viewingStreamId = undefined;
|
||||||
c.streamId = undefined;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset broadcaster role
|
// Reset broadcaster
|
||||||
const broadcaster = wsClients.get(stream.broadcasterId);
|
const broadcaster = wsClients.get(stream.broadcasterId);
|
||||||
if (broadcaster) {
|
if (broadcaster) {
|
||||||
broadcaster.role = 'idle';
|
broadcaster.broadcastStreamId = undefined;
|
||||||
broadcaster.streamId = undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
streams.delete(streamId);
|
streams.delete(streamId);
|
||||||
|
|
@ -80,14 +79,27 @@ function endStream(streamId: string, reason: string): void {
|
||||||
console.log(`[Streaming] Stream "${stream.title}" ended: ${reason}`);
|
console.log(`[Streaming] Stream "${stream.title}" ended: ${reason}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function leaveViewer(client: WsClient): void {
|
||||||
|
if (!client.viewingStreamId) return;
|
||||||
|
const stream = streams.get(client.viewingStreamId);
|
||||||
|
if (stream) {
|
||||||
|
stream.viewerCount = Math.max(0, stream.viewerCount - 1);
|
||||||
|
broadcastStreamStatus();
|
||||||
|
const broadcaster = wsClients.get(stream.broadcasterId);
|
||||||
|
if (broadcaster) {
|
||||||
|
sendTo(broadcaster, { type: 'viewer_left', viewerId: client.id, streamId: client.viewingStreamId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
client.viewingStreamId = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
// ── WebSocket Signaling ──
|
// ── WebSocket Signaling ──
|
||||||
|
|
||||||
function handleSignalingMessage(client: WsClient, msg: any): void {
|
function handleSignalingMessage(client: WsClient, msg: any): void {
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
// ── Broadcaster starts a stream ──
|
// ── Broadcaster starts a stream ──
|
||||||
case 'start_broadcast': {
|
case 'start_broadcast': {
|
||||||
// One broadcast per client
|
if (client.broadcastStreamId) {
|
||||||
if (client.role === 'broadcaster') {
|
|
||||||
sendTo(client, { type: 'error', code: 'ALREADY_BROADCASTING', message: 'Du streamst bereits.' });
|
sendTo(client, { type: 'error', code: 'ALREADY_BROADCASTING', message: 'Du streamst bereits.' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -100,9 +112,8 @@ function handleSignalingMessage(client: WsClient, msg: any): void {
|
||||||
const name = String(msg.name || 'Anon').slice(0, 32);
|
const name = String(msg.name || 'Anon').slice(0, 32);
|
||||||
const title = String(msg.title || 'Screen Share').slice(0, 64);
|
const title = String(msg.title || 'Screen Share').slice(0, 64);
|
||||||
|
|
||||||
client.role = 'broadcaster';
|
|
||||||
client.name = name;
|
client.name = name;
|
||||||
client.streamId = streamId;
|
client.broadcastStreamId = streamId;
|
||||||
|
|
||||||
streams.set(streamId, {
|
streams.set(streamId, {
|
||||||
id: streamId,
|
id: streamId,
|
||||||
|
|
@ -117,7 +128,7 @@ function handleSignalingMessage(client: WsClient, msg: any): void {
|
||||||
sendTo(client, { type: 'broadcast_started', streamId });
|
sendTo(client, { type: 'broadcast_started', streamId });
|
||||||
broadcastStreamStatus();
|
broadcastStreamStatus();
|
||||||
|
|
||||||
// Notify all idle clients
|
// Notify all other clients
|
||||||
for (const c of wsClients.values()) {
|
for (const c of wsClients.values()) {
|
||||||
if (c.id !== client.id) {
|
if (c.id !== client.id) {
|
||||||
sendTo(c, { type: 'stream_available', streamId, broadcasterName: name, title });
|
sendTo(c, { type: 'stream_available', streamId, broadcasterName: name, title });
|
||||||
|
|
@ -129,8 +140,8 @@ function handleSignalingMessage(client: WsClient, msg: any): void {
|
||||||
|
|
||||||
// ── Broadcaster stops ──
|
// ── Broadcaster stops ──
|
||||||
case 'stop_broadcast': {
|
case 'stop_broadcast': {
|
||||||
if (client.role !== 'broadcaster' || !client.streamId) return;
|
if (!client.broadcastStreamId) return;
|
||||||
endStream(client.streamId, 'Broadcaster hat den Stream beendet');
|
endStream(client.broadcastStreamId, 'Broadcaster hat den Stream beendet');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -149,9 +160,13 @@ function handleSignalingMessage(client: WsClient, msg: any): void {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
client.role = 'viewer';
|
// Leave current view if already viewing
|
||||||
client.name = String(msg.name || 'Viewer').slice(0, 32);
|
if (client.viewingStreamId) {
|
||||||
client.streamId = streamId;
|
leaveViewer(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
client.name = client.name || String(msg.name || 'Viewer').slice(0, 32);
|
||||||
|
client.viewingStreamId = streamId;
|
||||||
stream.viewerCount++;
|
stream.viewerCount++;
|
||||||
broadcastStreamStatus();
|
broadcastStreamStatus();
|
||||||
|
|
||||||
|
|
@ -165,18 +180,7 @@ function handleSignalingMessage(client: WsClient, msg: any): void {
|
||||||
|
|
||||||
// ── Viewer leaves ──
|
// ── Viewer leaves ──
|
||||||
case 'leave_viewer': {
|
case 'leave_viewer': {
|
||||||
if (client.role !== 'viewer' || !client.streamId) return;
|
leaveViewer(client);
|
||||||
const stream = streams.get(client.streamId);
|
|
||||||
if (stream) {
|
|
||||||
stream.viewerCount = Math.max(0, stream.viewerCount - 1);
|
|
||||||
broadcastStreamStatus();
|
|
||||||
const broadcaster = wsClients.get(stream.broadcasterId);
|
|
||||||
if (broadcaster) {
|
|
||||||
sendTo(broadcaster, { type: 'viewer_left', viewerId: client.id, streamId: client.streamId });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
client.role = 'idle';
|
|
||||||
client.streamId = undefined;
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -194,18 +198,13 @@ function handleSignalingMessage(client: WsClient, msg: any): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDisconnect(client: WsClient): void {
|
function handleDisconnect(client: WsClient): void {
|
||||||
if (client.role === 'broadcaster' && client.streamId) {
|
// Clean up broadcast
|
||||||
endStream(client.streamId, 'Broadcaster hat die Verbindung verloren');
|
if (client.broadcastStreamId) {
|
||||||
} else if (client.role === 'viewer' && client.streamId) {
|
endStream(client.broadcastStreamId, 'Broadcaster hat die Verbindung verloren');
|
||||||
const stream = streams.get(client.streamId);
|
|
||||||
if (stream) {
|
|
||||||
stream.viewerCount = Math.max(0, stream.viewerCount - 1);
|
|
||||||
broadcastStreamStatus();
|
|
||||||
const broadcaster = wsClients.get(stream.broadcasterId);
|
|
||||||
if (broadcaster) {
|
|
||||||
sendTo(broadcaster, { type: 'viewer_left', viewerId: client.id, streamId: client.streamId });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// Clean up viewing
|
||||||
|
if (client.viewingStreamId) {
|
||||||
|
leaveViewer(client);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -224,6 +223,25 @@ const streamingPlugin: Plugin = {
|
||||||
app.get('/api/streaming/status', (_req, res) => {
|
app.get('/api/streaming/status', (_req, res) => {
|
||||||
res.json({ streams: getStreamList() });
|
res.json({ streams: getStreamList() });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Beacon cleanup endpoint — called via navigator.sendBeacon() on page unload
|
||||||
|
app.post('/api/streaming/disconnect', (req, res) => {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', (chunk: Buffer) => { body += chunk.toString(); });
|
||||||
|
req.on('end', () => {
|
||||||
|
try {
|
||||||
|
const { clientId } = JSON.parse(body);
|
||||||
|
const client = wsClients.get(clientId);
|
||||||
|
if (client) {
|
||||||
|
console.log(`[Streaming] Beacon disconnect for ${client.name || clientId.slice(0, 8)}`);
|
||||||
|
handleDisconnect(client);
|
||||||
|
wsClients.delete(clientId);
|
||||||
|
client.ws.terminate();
|
||||||
|
}
|
||||||
|
} catch { /* ignore malformed */ }
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
getSnapshot(_ctx) {
|
getSnapshot(_ctx) {
|
||||||
|
|
@ -253,7 +271,7 @@ export function attachWebSocket(server: http.Server): void {
|
||||||
|
|
||||||
wss.on('connection', (ws) => {
|
wss.on('connection', (ws) => {
|
||||||
const clientId = crypto.randomUUID();
|
const clientId = crypto.randomUUID();
|
||||||
const client: WsClient = { id: clientId, ws, role: 'idle', name: '', isAlive: true };
|
const client: WsClient = { id: clientId, ws, name: '', isAlive: true };
|
||||||
wsClients.set(clientId, client);
|
wsClients.set(clientId, client);
|
||||||
|
|
||||||
sendTo(client, { type: 'welcome', clientId, streams: getStreamList() });
|
sendTo(client, { type: 'welcome', clientId, streams: getStreamList() });
|
||||||
|
|
@ -283,8 +301,7 @@ export function attachWebSocket(server: http.Server): void {
|
||||||
heartbeatInterval = setInterval(() => {
|
heartbeatInterval = setInterval(() => {
|
||||||
for (const [id, client] of wsClients) {
|
for (const [id, client] of wsClients) {
|
||||||
if (!client.isAlive) {
|
if (!client.isAlive) {
|
||||||
// No pong received since last check → dead
|
console.log(`[Streaming] Heartbeat timeout for ${client.name || id.slice(0, 8)}`);
|
||||||
console.log(`[Streaming] Heartbeat timeout for ${client.name || id.slice(0, 8)} (role=${client.role})`);
|
|
||||||
handleDisconnect(client);
|
handleDisconnect(client);
|
||||||
wsClients.delete(id);
|
wsClients.delete(id);
|
||||||
client.ws.terminate();
|
client.ws.terminate();
|
||||||
|
|
|
||||||
4830
web/dist/assets/index-DIgvA275.js
vendored
4830
web/dist/assets/index-DIgvA275.js
vendored
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-iGsoo-U1.js
vendored
Normal file
4830
web/dist/assets/index-iGsoo-U1.js
vendored
Normal file
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-DIgvA275.js"></script>
|
<script type="module" crossorigin src="/assets/index-iGsoo-U1.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-BL4zgtRP.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-DKX7sma7.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,9 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
const [isBroadcasting, setIsBroadcasting] = useState(false);
|
const [isBroadcasting, setIsBroadcasting] = useState(false);
|
||||||
const [starting, setStarting] = useState(false);
|
const [starting, setStarting] = useState(false);
|
||||||
const [viewing, setViewing] = useState<ViewState | null>(null);
|
const [viewing, setViewing] = useState<ViewState | null>(null);
|
||||||
const [, setTick] = useState(0); // for elapsed time re-render
|
const [, setTick] = useState(0);
|
||||||
|
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
||||||
|
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||||
|
|
||||||
// ── Refs ──
|
// ── Refs ──
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
|
@ -65,16 +67,13 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
const localStreamRef = useRef<MediaStream | null>(null);
|
const localStreamRef = useRef<MediaStream | null>(null);
|
||||||
const localVideoRef = useRef<HTMLVideoElement | null>(null);
|
const localVideoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
const remoteVideoRef = useRef<HTMLVideoElement | null>(null);
|
const remoteVideoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
/** Broadcaster: one PeerConnection per viewer */
|
|
||||||
const peerConnectionsRef = useRef<Map<string, RTCPeerConnection>>(new Map());
|
const peerConnectionsRef = useRef<Map<string, RTCPeerConnection>>(new Map());
|
||||||
/** Viewer: single PeerConnection to broadcaster */
|
|
||||||
const viewerPcRef = useRef<RTCPeerConnection | null>(null);
|
const viewerPcRef = useRef<RTCPeerConnection | null>(null);
|
||||||
/** ICE candidate queue — candidates that arrived before setRemoteDescription */
|
|
||||||
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);
|
||||||
|
|
||||||
// ── Refs that mirror state (to avoid stale closures in WS handler) ──
|
// Refs that mirror state (avoid stale closures in WS handler)
|
||||||
const isBroadcastingRef = useRef(false);
|
const isBroadcastingRef = useRef(false);
|
||||||
const viewingRef = useRef<ViewState | null>(null);
|
const viewingRef = useRef<ViewState | null>(null);
|
||||||
useEffect(() => { isBroadcastingRef.current = isBroadcasting; }, [isBroadcasting]);
|
useEffect(() => { isBroadcastingRef.current = isBroadcasting; }, [isBroadcasting]);
|
||||||
|
|
@ -100,6 +99,14 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
if (userName) localStorage.setItem('streaming_name', userName);
|
if (userName) localStorage.setItem('streaming_name', userName);
|
||||||
}, [userName]);
|
}, [userName]);
|
||||||
|
|
||||||
|
// ── Close tile menu on outside click ──
|
||||||
|
useEffect(() => {
|
||||||
|
if (!openMenu) return;
|
||||||
|
const handler = () => setOpenMenu(null);
|
||||||
|
document.addEventListener('click', handler);
|
||||||
|
return () => document.removeEventListener('click', handler);
|
||||||
|
}, [openMenu]);
|
||||||
|
|
||||||
// ── Send via WS ──
|
// ── Send via WS ──
|
||||||
const wsSend = useCallback((d: Record<string, any>) => {
|
const wsSend = useCallback((d: Record<string, any>) => {
|
||||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
|
@ -107,16 +114,13 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ── ICE candidate queuing (prevents candidates arriving before remote desc) ──
|
// ── ICE candidate queuing ──
|
||||||
const addOrQueueCandidate = useCallback((pc: RTCPeerConnection, peerId: string, candidate: RTCIceCandidateInit) => {
|
const addOrQueueCandidate = useCallback((pc: RTCPeerConnection, peerId: string, candidate: RTCIceCandidateInit) => {
|
||||||
if (pc.remoteDescription) {
|
if (pc.remoteDescription) {
|
||||||
pc.addIceCandidate(new RTCIceCandidate(candidate)).catch(() => {});
|
pc.addIceCandidate(new RTCIceCandidate(candidate)).catch(() => {});
|
||||||
} else {
|
} else {
|
||||||
let queue = pendingCandidatesRef.current.get(peerId);
|
let queue = pendingCandidatesRef.current.get(peerId);
|
||||||
if (!queue) {
|
if (!queue) { queue = []; pendingCandidatesRef.current.set(peerId, queue); }
|
||||||
queue = [];
|
|
||||||
pendingCandidatesRef.current.set(peerId, queue);
|
|
||||||
}
|
|
||||||
queue.push(candidate);
|
queue.push(candidate);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -124,21 +128,19 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
const flushCandidates = useCallback((pc: RTCPeerConnection, peerId: string) => {
|
const flushCandidates = useCallback((pc: RTCPeerConnection, peerId: string) => {
|
||||||
const queue = pendingCandidatesRef.current.get(peerId);
|
const queue = pendingCandidatesRef.current.get(peerId);
|
||||||
if (queue) {
|
if (queue) {
|
||||||
for (const c of queue) {
|
for (const c of queue) pc.addIceCandidate(new RTCIceCandidate(c)).catch(() => {});
|
||||||
pc.addIceCandidate(new RTCIceCandidate(c)).catch(() => {});
|
|
||||||
}
|
|
||||||
pendingCandidatesRef.current.delete(peerId);
|
pendingCandidatesRef.current.delete(peerId);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ── Viewer cleanup ──
|
// ── 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;
|
||||||
}
|
}
|
||||||
if (remoteVideoRef.current) remoteVideoRef.current.srcObject = null;
|
if (remoteVideoRef.current) remoteVideoRef.current.srcObject = null;
|
||||||
pendingCandidatesRef.current.clear();
|
// Only clear viewer-related pending candidates (not broadcaster ones)
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ── WS message handler (uses refs, never stale) ──
|
// ── WS message handler (uses refs, never stale) ──
|
||||||
|
|
@ -153,12 +155,11 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
case 'broadcast_started':
|
case 'broadcast_started':
|
||||||
setMyStreamId(msg.streamId);
|
setMyStreamId(msg.streamId);
|
||||||
setIsBroadcasting(true);
|
setIsBroadcasting(true);
|
||||||
isBroadcastingRef.current = true; // immediate update for handler
|
isBroadcastingRef.current = true;
|
||||||
setStarting(false);
|
setStarting(false);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'stream_available':
|
case 'stream_available':
|
||||||
// SSE will update streams list
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'stream_ended':
|
case 'stream_ended':
|
||||||
|
|
@ -171,8 +172,6 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
// ── Broadcaster: viewer joined → create offer ──
|
// ── Broadcaster: viewer joined → create offer ──
|
||||||
case 'viewer_joined': {
|
case 'viewer_joined': {
|
||||||
const viewerId = msg.viewerId;
|
const viewerId = msg.viewerId;
|
||||||
|
|
||||||
// Clean up existing connection if viewer re-joins
|
|
||||||
const existingPc = peerConnectionsRef.current.get(viewerId);
|
const existingPc = peerConnectionsRef.current.get(viewerId);
|
||||||
if (existingPc) {
|
if (existingPc) {
|
||||||
existingPc.close();
|
existingPc.close();
|
||||||
|
|
@ -183,49 +182,35 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
const pc = new RTCPeerConnection(RTC_CONFIG);
|
const pc = new RTCPeerConnection(RTC_CONFIG);
|
||||||
peerConnectionsRef.current.set(viewerId, pc);
|
peerConnectionsRef.current.set(viewerId, pc);
|
||||||
|
|
||||||
// Add local stream tracks
|
|
||||||
const stream = localStreamRef.current;
|
const stream = localStreamRef.current;
|
||||||
if (stream) {
|
if (stream) {
|
||||||
for (const track of stream.getTracks()) {
|
for (const track of stream.getTracks()) pc.addTrack(track, stream);
|
||||||
pc.addTrack(track, stream);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pc.onicecandidate = (ev) => {
|
pc.onicecandidate = (ev) => {
|
||||||
if (ev.candidate) {
|
if (ev.candidate) wsSend({ type: 'ice_candidate', targetId: viewerId, candidate: ev.candidate.toJSON() });
|
||||||
wsSend({ type: 'ice_candidate', targetId: viewerId, candidate: ev.candidate.toJSON() });
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Boost encoding: 60 fps + higher bitrate for smooth video
|
// 60 fps + high bitrate
|
||||||
const videoSender = pc.getSenders().find(s => s.track?.kind === 'video');
|
const videoSender = pc.getSenders().find(s => s.track?.kind === 'video');
|
||||||
if (videoSender) {
|
if (videoSender) {
|
||||||
const params = videoSender.getParameters();
|
const params = videoSender.getParameters();
|
||||||
if (!params.encodings || params.encodings.length === 0) {
|
if (!params.encodings || params.encodings.length === 0) params.encodings = [{}];
|
||||||
params.encodings = [{}];
|
|
||||||
}
|
|
||||||
params.encodings[0].maxFramerate = 60;
|
params.encodings[0].maxFramerate = 60;
|
||||||
params.encodings[0].maxBitrate = 8_000_000; // 8 Mbps
|
params.encodings[0].maxBitrate = 8_000_000;
|
||||||
videoSender.setParameters(params).catch(() => {});
|
videoSender.setParameters(params).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single offer (no onnegotiationneeded — tracks already added above)
|
|
||||||
pc.createOffer()
|
pc.createOffer()
|
||||||
.then(offer => pc.setLocalDescription(offer))
|
.then(offer => pc.setLocalDescription(offer))
|
||||||
.then(() => {
|
.then(() => wsSend({ type: 'offer', targetId: viewerId, sdp: pc.localDescription }))
|
||||||
wsSend({ type: 'offer', targetId: viewerId, sdp: pc.localDescription });
|
|
||||||
})
|
|
||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Broadcaster: viewer left → cleanup ──
|
|
||||||
case 'viewer_left': {
|
case 'viewer_left': {
|
||||||
const pc = peerConnectionsRef.current.get(msg.viewerId);
|
const pc = peerConnectionsRef.current.get(msg.viewerId);
|
||||||
if (pc) {
|
if (pc) { pc.close(); peerConnectionsRef.current.delete(msg.viewerId); }
|
||||||
pc.close();
|
|
||||||
peerConnectionsRef.current.delete(msg.viewerId);
|
|
||||||
}
|
|
||||||
pendingCandidatesRef.current.delete(msg.viewerId);
|
pendingCandidatesRef.current.delete(msg.viewerId);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -233,28 +218,19 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
// ── Viewer: received offer from broadcaster ──
|
// ── Viewer: received offer from broadcaster ──
|
||||||
case 'offer': {
|
case 'offer': {
|
||||||
const broadcasterId = msg.fromId;
|
const broadcasterId = msg.fromId;
|
||||||
|
if (viewerPcRef.current) { viewerPcRef.current.close(); viewerPcRef.current = null; }
|
||||||
// Close previous PC if exists (e.g. re-offer)
|
|
||||||
if (viewerPcRef.current) {
|
|
||||||
viewerPcRef.current.close();
|
|
||||||
viewerPcRef.current = null;
|
|
||||||
}
|
|
||||||
pendingCandidatesRef.current.delete(broadcasterId);
|
pendingCandidatesRef.current.delete(broadcasterId);
|
||||||
|
|
||||||
const pc = new RTCPeerConnection(RTC_CONFIG);
|
const pc = new RTCPeerConnection(RTC_CONFIG);
|
||||||
viewerPcRef.current = pc;
|
viewerPcRef.current = pc;
|
||||||
|
|
||||||
pc.ontrack = (ev) => {
|
pc.ontrack = (ev) => {
|
||||||
if (remoteVideoRef.current && ev.streams[0]) {
|
if (remoteVideoRef.current && ev.streams[0]) remoteVideoRef.current.srcObject = ev.streams[0];
|
||||||
remoteVideoRef.current.srcObject = ev.streams[0];
|
|
||||||
}
|
|
||||||
setViewing(prev => prev ? { ...prev, phase: 'connected' } : prev);
|
setViewing(prev => prev ? { ...prev, phase: 'connected' } : prev);
|
||||||
};
|
};
|
||||||
|
|
||||||
pc.onicecandidate = (ev) => {
|
pc.onicecandidate = (ev) => {
|
||||||
if (ev.candidate) {
|
if (ev.candidate) wsSend({ type: 'ice_candidate', targetId: broadcasterId, candidate: ev.candidate.toJSON() });
|
||||||
wsSend({ type: 'ice_candidate', targetId: broadcasterId, candidate: ev.candidate.toJSON() });
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pc.oniceconnectionstatechange = () => {
|
pc.oniceconnectionstatechange = () => {
|
||||||
|
|
@ -264,19 +240,13 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
};
|
};
|
||||||
|
|
||||||
pc.setRemoteDescription(new RTCSessionDescription(msg.sdp))
|
pc.setRemoteDescription(new RTCSessionDescription(msg.sdp))
|
||||||
.then(() => {
|
.then(() => { flushCandidates(pc, broadcasterId); return pc.createAnswer(); })
|
||||||
flushCandidates(pc, broadcasterId);
|
|
||||||
return pc.createAnswer();
|
|
||||||
})
|
|
||||||
.then(answer => pc.setLocalDescription(answer))
|
.then(answer => pc.setLocalDescription(answer))
|
||||||
.then(() => {
|
.then(() => wsSend({ type: 'answer', targetId: broadcasterId, sdp: pc.localDescription }))
|
||||||
wsSend({ type: 'answer', targetId: broadcasterId, sdp: pc.localDescription });
|
|
||||||
})
|
|
||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Broadcaster: received answer from viewer ──
|
|
||||||
case 'answer': {
|
case 'answer': {
|
||||||
const pc = peerConnectionsRef.current.get(msg.fromId);
|
const pc = peerConnectionsRef.current.get(msg.fromId);
|
||||||
if (pc) {
|
if (pc) {
|
||||||
|
|
@ -287,22 +257,20 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── ICE candidate relay (queued until remote desc is set) ──
|
// ── ICE: try broadcaster map first, then viewer PC (supports dual role) ──
|
||||||
case 'ice_candidate': {
|
case 'ice_candidate': {
|
||||||
if (!msg.candidate) break;
|
if (!msg.candidate) break;
|
||||||
if (isBroadcastingRef.current) {
|
const broadcasterPc = peerConnectionsRef.current.get(msg.fromId);
|
||||||
const pc = peerConnectionsRef.current.get(msg.fromId);
|
if (broadcasterPc) {
|
||||||
if (pc) addOrQueueCandidate(pc, msg.fromId, msg.candidate);
|
addOrQueueCandidate(broadcasterPc, msg.fromId, msg.candidate);
|
||||||
} else {
|
} else if (viewerPcRef.current) {
|
||||||
const pc = viewerPcRef.current;
|
addOrQueueCandidate(viewerPcRef.current, msg.fromId, msg.candidate);
|
||||||
if (pc) addOrQueueCandidate(pc, msg.fromId, msg.candidate);
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'error':
|
case 'error':
|
||||||
if (msg.code === 'WRONG_PASSWORD') {
|
if (msg.code === 'WRONG_PASSWORD') {
|
||||||
// Show error inside join modal
|
|
||||||
setJoinModal(prev => prev ? { ...prev, error: msg.message } : prev);
|
setJoinModal(prev => prev ? { ...prev, error: msg.message } : prev);
|
||||||
} else {
|
} else {
|
||||||
setError(msg.message);
|
setError(msg.message);
|
||||||
|
|
@ -312,7 +280,7 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── WebSocket connect (stable, no state deps) ──
|
// ── WebSocket connect ──
|
||||||
const connectWs = useCallback(() => {
|
const connectWs = useCallback(() => {
|
||||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) return;
|
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) return;
|
||||||
|
|
||||||
|
|
@ -320,11 +288,8 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
const ws = new WebSocket(`${proto}://${location.host}/ws/streaming`);
|
const ws = new WebSocket(`${proto}://${location.host}/ws/streaming`);
|
||||||
wsRef.current = ws;
|
wsRef.current = ws;
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => { reconnectDelayRef.current = 1000; };
|
||||||
reconnectDelayRef.current = 1000;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Delegate to ref so handler is always current
|
|
||||||
ws.onmessage = (ev) => {
|
ws.onmessage = (ev) => {
|
||||||
let msg: any;
|
let msg: any;
|
||||||
try { msg = JSON.parse(ev.data); } catch { return; }
|
try { msg = JSON.parse(ev.data); } catch { return; }
|
||||||
|
|
@ -333,7 +298,6 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
|
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
wsRef.current = null;
|
wsRef.current = null;
|
||||||
// Auto-reconnect if broadcasting or viewing (read from refs)
|
|
||||||
if (isBroadcastingRef.current || viewingRef.current) {
|
if (isBroadcastingRef.current || viewingRef.current) {
|
||||||
reconnectTimerRef.current = setTimeout(() => {
|
reconnectTimerRef.current = setTimeout(() => {
|
||||||
reconnectDelayRef.current = Math.min(reconnectDelayRef.current * 2, 10000);
|
reconnectDelayRef.current = Math.min(reconnectDelayRef.current * 2, 10000);
|
||||||
|
|
@ -342,16 +306,13 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onerror = () => {
|
ws.onerror = () => { ws.close(); };
|
||||||
ws.close();
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ── Start broadcasting ──
|
// ── Start broadcasting ──
|
||||||
const startBroadcast = useCallback(async () => {
|
const startBroadcast = useCallback(async () => {
|
||||||
if (!userName.trim()) { setError('Bitte gib einen Namen ein.'); return; }
|
if (!userName.trim()) { setError('Bitte gib einen Namen ein.'); return; }
|
||||||
if (!streamPassword.trim()) { setError('Passwort ist Pflicht.'); return; }
|
if (!streamPassword.trim()) { setError('Passwort ist Pflicht.'); return; }
|
||||||
|
|
||||||
if (!navigator.mediaDevices?.getDisplayMedia) {
|
if (!navigator.mediaDevices?.getDisplayMedia) {
|
||||||
setError('Dein Browser unterstützt keine Bildschirmfreigabe.');
|
setError('Dein Browser unterstützt keine Bildschirmfreigabe.');
|
||||||
return;
|
return;
|
||||||
|
|
@ -366,49 +327,33 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
audio: true,
|
audio: true,
|
||||||
});
|
});
|
||||||
localStreamRef.current = stream;
|
localStreamRef.current = stream;
|
||||||
|
if (localVideoRef.current) localVideoRef.current.srcObject = stream;
|
||||||
|
|
||||||
// Show local preview
|
stream.getVideoTracks()[0]?.addEventListener('ended', () => { stopBroadcast(); });
|
||||||
if (localVideoRef.current) {
|
|
||||||
localVideoRef.current.srcObject = stream;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-stop when user clicks native "Stop sharing"
|
|
||||||
stream.getVideoTracks()[0]?.addEventListener('ended', () => {
|
|
||||||
stopBroadcast();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Connect WS and start broadcast
|
|
||||||
connectWs();
|
connectWs();
|
||||||
|
|
||||||
const waitForWs = () => {
|
const waitForWs = () => {
|
||||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
wsSend({ type: 'start_broadcast', name: userName.trim(), title: streamTitle.trim() || 'Screen Share', password: streamPassword.trim() });
|
wsSend({ type: 'start_broadcast', name: userName.trim(), title: streamTitle.trim() || 'Screen Share', password: streamPassword.trim() });
|
||||||
} else {
|
} else { setTimeout(waitForWs, 100); }
|
||||||
setTimeout(waitForWs, 100);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
waitForWs();
|
waitForWs();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setStarting(false);
|
setStarting(false);
|
||||||
if (e.name === 'NotAllowedError') {
|
if (e.name === 'NotAllowedError') setError('Bildschirmfreigabe wurde abgelehnt.');
|
||||||
setError('Bildschirmfreigabe wurde abgelehnt.');
|
else setError(`Fehler: ${e.message}`);
|
||||||
} else {
|
|
||||||
setError(`Fehler: ${e.message}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [userName, streamTitle, streamPassword, connectWs, wsSend]);
|
}, [userName, streamTitle, streamPassword, connectWs, wsSend]);
|
||||||
|
|
||||||
// ── Stop broadcasting ──
|
// ── Stop broadcasting (keeps viewer connection intact) ──
|
||||||
const stopBroadcast = useCallback(() => {
|
const stopBroadcast = useCallback(() => {
|
||||||
wsSend({ type: 'stop_broadcast' });
|
wsSend({ type: 'stop_broadcast' });
|
||||||
|
|
||||||
localStreamRef.current?.getTracks().forEach(t => t.stop());
|
localStreamRef.current?.getTracks().forEach(t => t.stop());
|
||||||
localStreamRef.current = null;
|
localStreamRef.current = null;
|
||||||
if (localVideoRef.current) localVideoRef.current.srcObject = null;
|
if (localVideoRef.current) localVideoRef.current.srcObject = null;
|
||||||
|
|
||||||
for (const pc of peerConnectionsRef.current.values()) pc.close();
|
for (const pc of peerConnectionsRef.current.values()) pc.close();
|
||||||
peerConnectionsRef.current.clear();
|
peerConnectionsRef.current.clear();
|
||||||
pendingCandidatesRef.current.clear();
|
|
||||||
|
|
||||||
setIsBroadcasting(false);
|
setIsBroadcasting(false);
|
||||||
isBroadcastingRef.current = false;
|
isBroadcastingRef.current = false;
|
||||||
|
|
@ -416,15 +361,9 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
setStreamPassword('');
|
setStreamPassword('');
|
||||||
}, [wsSend]);
|
}, [wsSend]);
|
||||||
|
|
||||||
// ── Join as viewer (opens password modal first) ──
|
// ── Join as viewer ──
|
||||||
const openJoinModal = useCallback((s: StreamInfo) => {
|
const openJoinModal = useCallback((s: StreamInfo) => {
|
||||||
setJoinModal({
|
setJoinModal({ streamId: s.id, streamTitle: s.title, broadcasterName: s.broadcasterName, password: '', error: null });
|
||||||
streamId: s.id,
|
|
||||||
streamTitle: s.title,
|
|
||||||
broadcasterName: s.broadcasterName,
|
|
||||||
password: '',
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const submitJoinModal = useCallback(() => {
|
const submitJoinModal = useCallback(() => {
|
||||||
|
|
@ -442,9 +381,7 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
const waitForWs = () => {
|
const waitForWs = () => {
|
||||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
wsSend({ type: 'join_viewer', name: userName.trim() || 'Viewer', streamId, password: password.trim() });
|
wsSend({ type: 'join_viewer', name: userName.trim() || 'Viewer', streamId, password: password.trim() });
|
||||||
} else {
|
} else { setTimeout(waitForWs, 100); }
|
||||||
setTimeout(waitForWs, 100);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
waitForWs();
|
waitForWs();
|
||||||
}, [joinModal, userName, connectWs, wsSend]);
|
}, [joinModal, userName, connectWs, wsSend]);
|
||||||
|
|
@ -456,29 +393,34 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
setViewing(null);
|
setViewing(null);
|
||||||
}, [cleanupViewer, wsSend]);
|
}, [cleanupViewer, wsSend]);
|
||||||
|
|
||||||
// ── Warn before leaving page while active ──
|
// ── Warn before leaving + beacon cleanup ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: BeforeUnloadEvent) => {
|
const beforeUnload = (e: BeforeUnloadEvent) => {
|
||||||
if (isBroadcastingRef.current || viewingRef.current) {
|
if (isBroadcastingRef.current || viewingRef.current) e.preventDefault();
|
||||||
e.preventDefault();
|
};
|
||||||
|
const pageHide = () => {
|
||||||
|
// Guaranteed delivery via sendBeacon
|
||||||
|
if (clientIdRef.current) {
|
||||||
|
navigator.sendBeacon('/api/streaming/disconnect', JSON.stringify({ clientId: clientIdRef.current }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('beforeunload', handler);
|
window.addEventListener('beforeunload', beforeUnload);
|
||||||
return () => window.removeEventListener('beforeunload', handler);
|
window.addEventListener('pagehide', pageHide);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('beforeunload', beforeUnload);
|
||||||
|
window.removeEventListener('pagehide', pageHide);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ── Fullscreen toggle for viewer ──
|
// ── Fullscreen toggle ──
|
||||||
const viewerContainerRef = useRef<HTMLDivElement | null>(null);
|
const viewerContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
|
||||||
const toggleFullscreen = useCallback(() => {
|
const toggleFullscreen = useCallback(() => {
|
||||||
const el = viewerContainerRef.current;
|
const el = viewerContainerRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
if (!document.fullscreenElement) {
|
if (!document.fullscreenElement) el.requestFullscreen().catch(() => {});
|
||||||
el.requestFullscreen().catch(() => {});
|
else document.exitFullscreen().catch(() => {});
|
||||||
} else {
|
|
||||||
document.exitFullscreen().catch(() => {});
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -498,6 +440,50 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// ── Auto-join from URL ?viewStream=... ──
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const streamId = params.get('viewStream');
|
||||||
|
if (!streamId) return;
|
||||||
|
// Clean up URL
|
||||||
|
const url = new URL(location.href);
|
||||||
|
url.searchParams.delete('viewStream');
|
||||||
|
window.history.replaceState({}, '', url.toString());
|
||||||
|
// Wait for streams to load, then open join modal
|
||||||
|
const tryOpen = () => {
|
||||||
|
const s = streams.find(st => st.id === streamId);
|
||||||
|
if (s) { openJoinModal(s); return; }
|
||||||
|
// Retry a few times (stream list may not be loaded yet)
|
||||||
|
let tries = 0;
|
||||||
|
const iv = setInterval(() => {
|
||||||
|
tries++;
|
||||||
|
if (tries > 20) { clearInterval(iv); return; }
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
setTimeout(tryOpen, 500);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── 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]);
|
||||||
|
|
||||||
// ── Render ──
|
// ── Render ──
|
||||||
|
|
||||||
// Fullscreen viewer overlay
|
// Fullscreen viewer overlay
|
||||||
|
|
@ -511,7 +497,7 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
<div>
|
<div>
|
||||||
<div className="stream-viewer-title">{stream?.title || 'Stream'}</div>
|
<div className="stream-viewer-title">{stream?.title || 'Stream'}</div>
|
||||||
<div className="stream-viewer-subtitle">
|
<div className="stream-viewer-subtitle">
|
||||||
{stream?.broadcasterName || '...'} {stream ? `\u00B7 ${stream.viewerCount} Zuschauer` : ''}
|
{stream?.broadcasterName || '...'} {stream ? ` · ${stream.viewerCount} Zuschauer` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -542,7 +528,6 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="stream-container">
|
<div className="stream-container">
|
||||||
{/* Error */}
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="stream-error">
|
<div className="stream-error">
|
||||||
{error}
|
{error}
|
||||||
|
|
@ -550,7 +535,6 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Top bar: name, title, start/stop */}
|
|
||||||
<div className="stream-topbar">
|
<div className="stream-topbar">
|
||||||
<input
|
<input
|
||||||
className="stream-input stream-input-name"
|
className="stream-input stream-input-name"
|
||||||
|
|
@ -585,7 +569,6 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Grid */}
|
|
||||||
{streams.length === 0 && !isBroadcasting ? (
|
{streams.length === 0 && !isBroadcasting ? (
|
||||||
<div className="stream-empty">
|
<div className="stream-empty">
|
||||||
<div className="stream-empty-icon">{'\u{1F4FA}'}</div>
|
<div className="stream-empty-icon">{'\u{1F4FA}'}</div>
|
||||||
|
|
@ -594,7 +577,6 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="stream-grid">
|
<div className="stream-grid">
|
||||||
{/* Own broadcast tile (with local preview) */}
|
|
||||||
{isBroadcasting && (
|
{isBroadcasting && (
|
||||||
<div className="stream-tile own broadcasting">
|
<div className="stream-tile own broadcasting">
|
||||||
<div className="stream-tile-preview">
|
<div className="stream-tile-preview">
|
||||||
|
|
@ -618,7 +600,6 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Other streams */}
|
|
||||||
{streams
|
{streams
|
||||||
.filter(s => s.id !== myStreamId)
|
.filter(s => s.id !== myStreamId)
|
||||||
.map(s => (
|
.map(s => (
|
||||||
|
|
@ -635,14 +616,38 @@ export default function StreamingTab({ data }: { data: any }) {
|
||||||
<div className="stream-tile-title">{s.title}</div>
|
<div className="stream-tile-title">{s.title}</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="stream-tile-time">{formatElapsed(s.startedAt)}</span>
|
<span className="stream-tile-time">{formatElapsed(s.startedAt)}</span>
|
||||||
<button className="stream-tile-menu" onClick={e => e.stopPropagation()}>{'\u22EE'}</button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Password join modal */}
|
|
||||||
{joinModal && (
|
{joinModal && (
|
||||||
<div className="stream-pw-overlay" onClick={() => setJoinModal(null)}>
|
<div className="stream-pw-overlay" onClick={() => setJoinModal(null)}>
|
||||||
<div className="stream-pw-modal" onClick={e => e.stopPropagation()}>
|
<div className="stream-pw-modal" onClick={e => e.stopPropagation()}>
|
||||||
|
|
|
||||||
|
|
@ -182,12 +182,15 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Three-dot menu */
|
/* Three-dot menu */
|
||||||
|
.stream-tile-menu-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
.stream-tile-menu {
|
.stream-tile-menu {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 4px;
|
padding: 4px 6px;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
|
@ -198,6 +201,59 @@
|
||||||
color: var(--text-normal);
|
color: var(--text-normal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dropdown menu */
|
||||||
|
.stream-tile-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 6px);
|
||||||
|
right: 0;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--bg-tertiary);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
min-width: 220px;
|
||||||
|
z-index: 100;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.stream-tile-dropdown-header {
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
.stream-tile-dropdown-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-normal);
|
||||||
|
}
|
||||||
|
.stream-tile-dropdown-title {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.stream-tile-dropdown-detail {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.stream-tile-dropdown-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
.stream-tile-dropdown-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--text-normal);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: background var(--transition);
|
||||||
|
}
|
||||||
|
.stream-tile-dropdown-item:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Fullscreen Viewer ── */
|
/* ── Fullscreen Viewer ── */
|
||||||
.stream-viewer-overlay {
|
.stream-viewer-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue