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:
Daniel 2026-03-07 01:56:14 +01:00
parent 813e017036
commit 470bef62e4
7 changed files with 5091 additions and 5013 deletions

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

File diff suppressed because one or more lines are too long

4
web/dist/index.html vendored
View file

@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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>" />
<script type="module" crossorigin src="/assets/index-DIgvA275.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BL4zgtRP.css">
<script type="module" crossorigin src="/assets/index-iGsoo-U1.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DKX7sma7.css">
</head>
<body>
<div id="root"></div>

View file

@ -57,7 +57,9 @@ export default function StreamingTab({ data }: { data: any }) {
const [isBroadcasting, setIsBroadcasting] = useState(false);
const [starting, setStarting] = useState(false);
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 ──
const wsRef = useRef<WebSocket | null>(null);
@ -65,16 +67,13 @@ export default function StreamingTab({ data }: { data: any }) {
const localStreamRef = useRef<MediaStream | null>(null);
const localVideoRef = useRef<HTMLVideoElement | null>(null);
const remoteVideoRef = useRef<HTMLVideoElement | null>(null);
/** Broadcaster: one PeerConnection per viewer */
const peerConnectionsRef = useRef<Map<string, RTCPeerConnection>>(new Map());
/** Viewer: single PeerConnection to broadcaster */
const viewerPcRef = useRef<RTCPeerConnection | null>(null);
/** ICE candidate queue — candidates that arrived before setRemoteDescription */
const pendingCandidatesRef = useRef<Map<string, RTCIceCandidateInit[]>>(new Map());
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
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 viewingRef = useRef<ViewState | null>(null);
useEffect(() => { isBroadcastingRef.current = isBroadcasting; }, [isBroadcasting]);
@ -100,6 +99,14 @@ export default function StreamingTab({ data }: { data: any }) {
if (userName) localStorage.setItem('streaming_name', 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 ──
const wsSend = useCallback((d: Record<string, any>) => {
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) => {
if (pc.remoteDescription) {
pc.addIceCandidate(new RTCIceCandidate(candidate)).catch(() => {});
} else {
let queue = pendingCandidatesRef.current.get(peerId);
if (!queue) {
queue = [];
pendingCandidatesRef.current.set(peerId, queue);
}
if (!queue) { queue = []; pendingCandidatesRef.current.set(peerId, queue); }
queue.push(candidate);
}
}, []);
@ -124,21 +128,19 @@ export default function StreamingTab({ data }: { data: any }) {
const flushCandidates = useCallback((pc: RTCPeerConnection, peerId: string) => {
const queue = pendingCandidatesRef.current.get(peerId);
if (queue) {
for (const c of queue) {
pc.addIceCandidate(new RTCIceCandidate(c)).catch(() => {});
}
for (const c of queue) pc.addIceCandidate(new RTCIceCandidate(c)).catch(() => {});
pendingCandidatesRef.current.delete(peerId);
}
}, []);
// ── Viewer cleanup ──
// ── Viewer cleanup (only viewer PC, keeps broadcaster intact) ──
const cleanupViewer = useCallback(() => {
if (viewerPcRef.current) {
viewerPcRef.current.close();
viewerPcRef.current = 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) ──
@ -153,12 +155,11 @@ export default function StreamingTab({ data }: { data: any }) {
case 'broadcast_started':
setMyStreamId(msg.streamId);
setIsBroadcasting(true);
isBroadcastingRef.current = true; // immediate update for handler
isBroadcastingRef.current = true;
setStarting(false);
break;
case 'stream_available':
// SSE will update streams list
break;
case 'stream_ended':
@ -171,8 +172,6 @@ export default function StreamingTab({ data }: { data: any }) {
// ── Broadcaster: viewer joined → create offer ──
case 'viewer_joined': {
const viewerId = msg.viewerId;
// Clean up existing connection if viewer re-joins
const existingPc = peerConnectionsRef.current.get(viewerId);
if (existingPc) {
existingPc.close();
@ -183,49 +182,35 @@ export default function StreamingTab({ data }: { data: any }) {
const pc = new RTCPeerConnection(RTC_CONFIG);
peerConnectionsRef.current.set(viewerId, pc);
// Add local stream tracks
const stream = localStreamRef.current;
if (stream) {
for (const track of stream.getTracks()) {
pc.addTrack(track, stream);
}
for (const track of stream.getTracks()) pc.addTrack(track, stream);
}
pc.onicecandidate = (ev) => {
if (ev.candidate) {
wsSend({ type: 'ice_candidate', targetId: viewerId, candidate: ev.candidate.toJSON() });
}
if (ev.candidate) 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');
if (videoSender) {
const params = videoSender.getParameters();
if (!params.encodings || params.encodings.length === 0) {
params.encodings = [{}];
}
if (!params.encodings || params.encodings.length === 0) params.encodings = [{}];
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(() => {});
}
// Single offer (no onnegotiationneeded — tracks already added above)
pc.createOffer()
.then(offer => pc.setLocalDescription(offer))
.then(() => {
wsSend({ type: 'offer', targetId: viewerId, sdp: pc.localDescription });
})
.then(() => wsSend({ type: 'offer', targetId: viewerId, sdp: pc.localDescription }))
.catch(console.error);
break;
}
// ── Broadcaster: viewer left → cleanup ──
case 'viewer_left': {
const pc = peerConnectionsRef.current.get(msg.viewerId);
if (pc) {
pc.close();
peerConnectionsRef.current.delete(msg.viewerId);
}
if (pc) { pc.close(); peerConnectionsRef.current.delete(msg.viewerId); }
pendingCandidatesRef.current.delete(msg.viewerId);
break;
}
@ -233,28 +218,19 @@ export default function StreamingTab({ data }: { data: any }) {
// ── Viewer: received offer from broadcaster ──
case 'offer': {
const broadcasterId = msg.fromId;
// Close previous PC if exists (e.g. re-offer)
if (viewerPcRef.current) {
viewerPcRef.current.close();
viewerPcRef.current = null;
}
if (viewerPcRef.current) { viewerPcRef.current.close(); viewerPcRef.current = null; }
pendingCandidatesRef.current.delete(broadcasterId);
const pc = new RTCPeerConnection(RTC_CONFIG);
viewerPcRef.current = pc;
pc.ontrack = (ev) => {
if (remoteVideoRef.current && ev.streams[0]) {
remoteVideoRef.current.srcObject = ev.streams[0];
}
if (remoteVideoRef.current && ev.streams[0]) remoteVideoRef.current.srcObject = ev.streams[0];
setViewing(prev => prev ? { ...prev, phase: 'connected' } : prev);
};
pc.onicecandidate = (ev) => {
if (ev.candidate) {
wsSend({ type: 'ice_candidate', targetId: broadcasterId, candidate: ev.candidate.toJSON() });
}
if (ev.candidate) wsSend({ type: 'ice_candidate', targetId: broadcasterId, candidate: ev.candidate.toJSON() });
};
pc.oniceconnectionstatechange = () => {
@ -264,19 +240,13 @@ export default function StreamingTab({ data }: { data: any }) {
};
pc.setRemoteDescription(new RTCSessionDescription(msg.sdp))
.then(() => {
flushCandidates(pc, broadcasterId);
return pc.createAnswer();
})
.then(() => { flushCandidates(pc, broadcasterId); return pc.createAnswer(); })
.then(answer => pc.setLocalDescription(answer))
.then(() => {
wsSend({ type: 'answer', targetId: broadcasterId, sdp: pc.localDescription });
})
.then(() => wsSend({ type: 'answer', targetId: broadcasterId, sdp: pc.localDescription }))
.catch(console.error);
break;
}
// ── Broadcaster: received answer from viewer ──
case 'answer': {
const pc = peerConnectionsRef.current.get(msg.fromId);
if (pc) {
@ -287,22 +257,20 @@ export default function StreamingTab({ data }: { data: any }) {
break;
}
// ── ICE candidate relay (queued until remote desc is set) ──
// ── ICE: try broadcaster map first, then viewer PC (supports dual role) ──
case 'ice_candidate': {
if (!msg.candidate) break;
if (isBroadcastingRef.current) {
const pc = peerConnectionsRef.current.get(msg.fromId);
if (pc) addOrQueueCandidate(pc, msg.fromId, msg.candidate);
} else {
const pc = viewerPcRef.current;
if (pc) addOrQueueCandidate(pc, msg.fromId, msg.candidate);
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);
}
break;
}
case 'error':
if (msg.code === 'WRONG_PASSWORD') {
// Show error inside join modal
setJoinModal(prev => prev ? { ...prev, error: msg.message } : prev);
} else {
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(() => {
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`);
wsRef.current = ws;
ws.onopen = () => {
reconnectDelayRef.current = 1000;
};
ws.onopen = () => { reconnectDelayRef.current = 1000; };
// Delegate to ref so handler is always current
ws.onmessage = (ev) => {
let msg: any;
try { msg = JSON.parse(ev.data); } catch { return; }
@ -333,7 +298,6 @@ export default function StreamingTab({ data }: { data: any }) {
ws.onclose = () => {
wsRef.current = null;
// Auto-reconnect if broadcasting or viewing (read from refs)
if (isBroadcastingRef.current || viewingRef.current) {
reconnectTimerRef.current = setTimeout(() => {
reconnectDelayRef.current = Math.min(reconnectDelayRef.current * 2, 10000);
@ -342,16 +306,13 @@ export default function StreamingTab({ data }: { data: any }) {
}
};
ws.onerror = () => {
ws.close();
};
ws.onerror = () => { ws.close(); };
}, []);
// ── Start broadcasting ──
const startBroadcast = useCallback(async () => {
if (!userName.trim()) { setError('Bitte gib einen Namen ein.'); return; }
if (!streamPassword.trim()) { setError('Passwort ist Pflicht.'); return; }
if (!navigator.mediaDevices?.getDisplayMedia) {
setError('Dein Browser unterstützt keine Bildschirmfreigabe.');
return;
@ -366,49 +327,33 @@ export default function StreamingTab({ data }: { data: any }) {
audio: true,
});
localStreamRef.current = stream;
if (localVideoRef.current) localVideoRef.current.srcObject = stream;
// Show local preview
if (localVideoRef.current) {
localVideoRef.current.srcObject = stream;
}
stream.getVideoTracks()[0]?.addEventListener('ended', () => { stopBroadcast(); });
// Auto-stop when user clicks native "Stop sharing"
stream.getVideoTracks()[0]?.addEventListener('ended', () => {
stopBroadcast();
});
// Connect WS and start broadcast
connectWs();
const waitForWs = () => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsSend({ type: 'start_broadcast', name: userName.trim(), title: streamTitle.trim() || 'Screen Share', password: streamPassword.trim() });
} else {
setTimeout(waitForWs, 100);
}
} else { setTimeout(waitForWs, 100); }
};
waitForWs();
} catch (e: any) {
setStarting(false);
if (e.name === 'NotAllowedError') {
setError('Bildschirmfreigabe wurde abgelehnt.');
} else {
setError(`Fehler: ${e.message}`);
}
if (e.name === 'NotAllowedError') setError('Bildschirmfreigabe wurde abgelehnt.');
else setError(`Fehler: ${e.message}`);
}
}, [userName, streamTitle, streamPassword, connectWs, wsSend]);
// ── Stop broadcasting ──
// ── Stop broadcasting (keeps viewer connection intact) ──
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();
pendingCandidatesRef.current.clear();
setIsBroadcasting(false);
isBroadcastingRef.current = false;
@ -416,15 +361,9 @@ export default function StreamingTab({ data }: { data: any }) {
setStreamPassword('');
}, [wsSend]);
// ── Join as viewer (opens password modal first) ──
// ── Join as viewer ──
const openJoinModal = useCallback((s: StreamInfo) => {
setJoinModal({
streamId: s.id,
streamTitle: s.title,
broadcasterName: s.broadcasterName,
password: '',
error: null,
});
setJoinModal({ streamId: s.id, streamTitle: s.title, broadcasterName: s.broadcasterName, password: '', error: null });
}, []);
const submitJoinModal = useCallback(() => {
@ -442,9 +381,7 @@ export default function StreamingTab({ data }: { data: any }) {
const waitForWs = () => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsSend({ type: 'join_viewer', name: userName.trim() || 'Viewer', streamId, password: password.trim() });
} else {
setTimeout(waitForWs, 100);
}
} else { setTimeout(waitForWs, 100); }
};
waitForWs();
}, [joinModal, userName, connectWs, wsSend]);
@ -456,29 +393,34 @@ export default function StreamingTab({ data }: { data: any }) {
setViewing(null);
}, [cleanupViewer, wsSend]);
// ── Warn before leaving page while active ──
// ── Warn before leaving + beacon cleanup ──
useEffect(() => {
const handler = (e: BeforeUnloadEvent) => {
if (isBroadcastingRef.current || viewingRef.current) {
e.preventDefault();
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 }));
}
};
window.addEventListener('beforeunload', handler);
return () => window.removeEventListener('beforeunload', handler);
window.addEventListener('beforeunload', beforeUnload);
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 [isFullscreen, setIsFullscreen] = useState(false);
const toggleFullscreen = useCallback(() => {
const el = viewerContainerRef.current;
if (!el) return;
if (!document.fullscreenElement) {
el.requestFullscreen().catch(() => {});
} else {
document.exitFullscreen().catch(() => {});
}
if (!document.fullscreenElement) el.requestFullscreen().catch(() => {});
else document.exitFullscreen().catch(() => {});
}, []);
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 ──
// Fullscreen viewer overlay
@ -511,7 +497,7 @@ export default function StreamingTab({ data }: { data: any }) {
<div>
<div className="stream-viewer-title">{stream?.title || 'Stream'}</div>
<div className="stream-viewer-subtitle">
{stream?.broadcasterName || '...'} {stream ? `\u00B7 ${stream.viewerCount} Zuschauer` : ''}
{stream?.broadcasterName || '...'} {stream ? ` · ${stream.viewerCount} Zuschauer` : ''}
</div>
</div>
</div>
@ -542,7 +528,6 @@ export default function StreamingTab({ data }: { data: any }) {
return (
<div className="stream-container">
{/* Error */}
{error && (
<div className="stream-error">
{error}
@ -550,7 +535,6 @@ export default function StreamingTab({ data }: { data: any }) {
</div>
)}
{/* Top bar: name, title, start/stop */}
<div className="stream-topbar">
<input
className="stream-input stream-input-name"
@ -585,7 +569,6 @@ export default function StreamingTab({ data }: { data: any }) {
)}
</div>
{/* Grid */}
{streams.length === 0 && !isBroadcasting ? (
<div className="stream-empty">
<div className="stream-empty-icon">{'\u{1F4FA}'}</div>
@ -594,7 +577,6 @@ export default function StreamingTab({ data }: { data: any }) {
</div>
) : (
<div className="stream-grid">
{/* Own broadcast tile (with local preview) */}
{isBroadcasting && (
<div className="stream-tile own broadcasting">
<div className="stream-tile-preview">
@ -618,7 +600,6 @@ export default function StreamingTab({ data }: { data: any }) {
</div>
)}
{/* Other streams */}
{streams
.filter(s => s.id !== myStreamId)
.map(s => (
@ -635,14 +616,38 @@ export default function StreamingTab({ data }: { data: any }) {
<div className="stream-tile-title">{s.title}</div>
</div>
<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>
)}
{/* Password join modal */}
{joinModal && (
<div className="stream-pw-overlay" onClick={() => setJoinModal(null)}>
<div className="stream-pw-modal" onClick={e => e.stopPropagation()}>

View file

@ -182,12 +182,15 @@
}
/* Three-dot menu */
.stream-tile-menu-wrap {
position: relative;
}
.stream-tile-menu {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 4px;
padding: 4px 6px;
font-size: 18px;
line-height: 1;
border-radius: 4px;
@ -198,6 +201,59 @@
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 ── */
.stream-viewer-overlay {
position: fixed;