diff --git a/server/src/index.ts b/server/src/index.ts index a80b870..f1f3cf2 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,5 +1,6 @@ import express from 'express'; import path from 'node:path'; +import http from 'node:http'; import { Client } from 'discord.js'; import { createClient } from './core/discord.js'; import { addSSEClient, removeSSEClient, sseBroadcast, getSSEClientCount } from './core/sse.js'; @@ -8,6 +9,7 @@ import { getPlugins, registerPlugin, getPluginCtx, type PluginContext } from './ import radioPlugin from './plugins/radio/index.js'; import soundboardPlugin from './plugins/soundboard/index.js'; import lolstatsPlugin from './plugins/lolstats/index.js'; +import streamingPlugin, { attachWebSocket } from './plugins/streaming/index.js'; // ── Config ── const PORT = Number(process.env.PORT ?? 8080); @@ -128,6 +130,11 @@ async function boot(): Promise { const ctxLolstats: PluginContext = { client: clientLolstats, dataDir: DATA_DIR, adminPwd: ADMIN_PWD, allowedGuildIds: ALLOWED_GUILD_IDS }; registerPlugin(lolstatsPlugin, ctxLolstats); + // streaming has no Discord bot — use a dummy client + const clientStreaming = createClient(); + const ctxStreaming: PluginContext = { client: clientStreaming, dataDir: DATA_DIR, adminPwd: ADMIN_PWD, allowedGuildIds: ALLOWED_GUILD_IDS }; + registerPlugin(streamingPlugin, ctxStreaming); + // Init all plugins for (const p of getPlugins()) { const pCtx = getPluginCtx(p.name)!; @@ -145,8 +152,10 @@ async function boot(): Promise { res.sendFile(path.join(import.meta.dirname ?? __dirname, '..', '..', 'web', 'dist', 'index.html')); }); - // Start Express - app.listen(PORT, () => console.log(`[HTTP] Listening on :${PORT}`)); + // Start Express (http.createServer so WebSocket can attach) + const httpServer = http.createServer(app); + attachWebSocket(httpServer); + httpServer.listen(PORT, () => console.log(`[HTTP] Listening on :${PORT}`)); // Login Discord bots for (const bot of clients) { diff --git a/server/src/plugins/streaming/index.ts b/server/src/plugins/streaming/index.ts new file mode 100644 index 0000000..b7fc679 --- /dev/null +++ b/server/src/plugins/streaming/index.ts @@ -0,0 +1,262 @@ +import type express from 'express'; +import http from 'node:http'; +import { WebSocketServer, WebSocket } from 'ws'; +import crypto from 'node:crypto'; +import type { Plugin, PluginContext } from '../../core/plugin.js'; +import { sseBroadcast } from '../../core/sse.js'; + +// ── Types ── + +interface StreamInfo { + id: string; + broadcasterName: string; + title: string; + startedAt: string; + viewerCount: number; +} + +interface WsClient { + id: string; + ws: WebSocket; + role: 'idle' | 'broadcaster' | 'viewer'; + name: string; + streamId?: string; // ID of stream this client broadcasts or views +} + +// ── State ── + +/** Active streams keyed by stream ID */ +const streams = new Map(); +/** All connected WS clients */ +const wsClients = new Map(); + +let wss: WebSocketServer | null = null; + +// ── Helpers ── + +function broadcastStreamStatus(): void { + const list = [...streams.values()].map(({ broadcasterId: _, ...s }) => s); + sseBroadcast({ type: 'streaming_status', plugin: 'streaming', streams: list }); +} + +function sendTo(client: WsClient, data: Record): void { + if (client.ws.readyState === WebSocket.OPEN) { + client.ws.send(JSON.stringify(data)); + } +} + +function getStreamList(): StreamInfo[] { + return [...streams.values()].map(({ broadcasterId: _, ...s }) => s); +} + +function endStream(streamId: string, reason: string): void { + const stream = streams.get(streamId); + if (!stream) return; + + // Notify all viewers of this stream + for (const c of wsClients.values()) { + if (c.role === 'viewer' && c.streamId === streamId) { + sendTo(c, { type: 'stream_ended', streamId, reason }); + c.role = 'idle'; + c.streamId = undefined; + } + } + + // Reset broadcaster role + const broadcaster = wsClients.get(stream.broadcasterId); + if (broadcaster) { + broadcaster.role = 'idle'; + broadcaster.streamId = undefined; + } + + streams.delete(streamId); + broadcastStreamStatus(); + console.log(`[Streaming] Stream "${stream.title}" ended: ${reason}`); +} + +// ── WebSocket Signaling ── + +function handleSignalingMessage(client: WsClient, msg: any): void { + switch (msg.type) { + // ── Broadcaster starts a stream ── + case 'start_broadcast': { + // One broadcast per client + if (client.role === 'broadcaster') { + sendTo(client, { type: 'error', code: 'ALREADY_BROADCASTING', message: 'Du streamst bereits.' }); + return; + } + const streamId = crypto.randomUUID(); + const name = String(msg.name || 'Anon').slice(0, 32); + const title = String(msg.title || 'Screen Share').slice(0, 64); + + client.role = 'broadcaster'; + client.name = name; + client.streamId = streamId; + + streams.set(streamId, { + id: streamId, + broadcasterId: client.id, + broadcasterName: name, + title, + startedAt: new Date().toISOString(), + viewerCount: 0, + }); + + sendTo(client, { type: 'broadcast_started', streamId }); + broadcastStreamStatus(); + + // Notify all idle clients + for (const c of wsClients.values()) { + if (c.id !== client.id) { + sendTo(c, { type: 'stream_available', streamId, broadcasterName: name, title }); + } + } + console.log(`[Streaming] ${name} started "${title}" (${streamId.slice(0, 8)})`); + break; + } + + // ── Broadcaster stops ── + case 'stop_broadcast': { + if (client.role !== 'broadcaster' || !client.streamId) return; + endStream(client.streamId, 'Broadcaster hat den Stream beendet'); + break; + } + + // ── Viewer joins a stream ── + case 'join_viewer': { + const streamId = msg.streamId; + const stream = streams.get(streamId); + if (!stream) { + sendTo(client, { type: 'error', code: 'NO_STREAM', message: 'Stream nicht gefunden.' }); + return; + } + + client.role = 'viewer'; + client.name = String(msg.name || 'Viewer').slice(0, 32); + client.streamId = streamId; + stream.viewerCount++; + broadcastStreamStatus(); + + // Tell broadcaster to create an offer for this viewer + const broadcaster = wsClients.get(stream.broadcasterId); + if (broadcaster) { + sendTo(broadcaster, { type: 'viewer_joined', viewerId: client.id, streamId }); + } + break; + } + + // ── Viewer leaves ── + case 'leave_viewer': { + if (client.role !== 'viewer' || !client.streamId) return; + 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; + } + + // ── WebRTC signaling relay ── + case 'offer': + case 'answer': + case 'ice_candidate': { + const target = wsClients.get(msg.targetId); + if (target) { + sendTo(target, { ...msg, fromId: client.id }); + } + break; + } + } +} + +function handleDisconnect(client: WsClient): void { + if (client.role === 'broadcaster' && client.streamId) { + endStream(client.streamId, 'Broadcaster hat die Verbindung verloren'); + } else if (client.role === 'viewer' && client.streamId) { + 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 }); + } + } + } +} + +// ── Plugin ── + +const streamingPlugin: Plugin = { + name: 'streaming', + version: '1.0.0', + description: 'Screen Streaming', + + async init(_ctx) { + console.log('[Streaming] Initialized'); + }, + + registerRoutes(app: express.Application, _ctx: PluginContext) { + app.get('/api/streaming/status', (_req, res) => { + res.json({ streams: getStreamList() }); + }); + }, + + getSnapshot(_ctx) { + return { + streaming: { streams: getStreamList() }, + }; + }, + + async destroy() { + if (wss) { + for (const client of wsClients.values()) { + client.ws.close(1001, 'Server shutting down'); + } + wsClients.clear(); + streams.clear(); + wss.close(); + wss = null; + } + console.log('[Streaming] Destroyed'); + }, +}; + +/** Call after httpServer is created to attach WebSocket signaling */ +export function attachWebSocket(server: http.Server): void { + wss = new WebSocketServer({ server, path: '/ws/streaming' }); + + wss.on('connection', (ws) => { + const clientId = crypto.randomUUID(); + const client: WsClient = { id: clientId, ws, role: 'idle', name: '' }; + wsClients.set(clientId, client); + + sendTo(client, { type: 'welcome', clientId, streams: getStreamList() }); + + ws.on('message', (raw) => { + let msg: any; + try { msg = JSON.parse(raw.toString()); } catch { return; } + handleSignalingMessage(client, msg); + }); + + ws.on('close', () => { + handleDisconnect(client); + wsClients.delete(clientId); + }); + + ws.on('error', () => { + handleDisconnect(client); + wsClients.delete(clientId); + }); + }); + + console.log('[Streaming] WebSocket signaling attached at /ws/streaming'); +} + +export default streamingPlugin; diff --git a/web/src/App.tsx b/web/src/App.tsx index 2e8da78..88b1a00 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react'; import RadioTab from './plugins/radio/RadioTab'; import SoundboardTab from './plugins/soundboard/SoundboardTab'; import LolstatsTab from './plugins/lolstats/LolstatsTab'; +import StreamingTab from './plugins/streaming/StreamingTab'; interface PluginInfo { name: string; @@ -14,6 +15,7 @@ const tabComponents: Record> = { radio: RadioTab, soundboard: SoundboardTab, lolstats: LolstatsTab, + streaming: StreamingTab, }; export function registerTab(pluginName: string, component: React.FC<{ data: any }>) { @@ -85,6 +87,7 @@ export default function App() { events: '\u{1F4C5}', games: '\u{1F3B2}', gamevote: '\u{1F3AE}', + streaming: '\u{1F4FA}', }; return ( diff --git a/web/src/plugins/streaming/StreamingTab.tsx b/web/src/plugins/streaming/StreamingTab.tsx new file mode 100644 index 0000000..8cee2bd --- /dev/null +++ b/web/src/plugins/streaming/StreamingTab.tsx @@ -0,0 +1,492 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import './streaming.css'; + +// ── Types ── + +interface StreamInfo { + id: string; + broadcasterName: string; + title: string; + startedAt: string; + viewerCount: number; +} + +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')}`; +} + +// ── Component ── + +export default function StreamingTab({ data }: { data: any }) { + // ── State ── + const [streams, setStreams] = useState([]); + const [userName, setUserName] = useState(() => localStorage.getItem('streaming_name') || ''); + const [streamTitle, setStreamTitle] = useState('Screen Share'); + const [error, setError] = useState(null); + const [myStreamId, setMyStreamId] = useState(null); + const [isBroadcasting, setIsBroadcasting] = useState(false); + const [starting, setStarting] = useState(false); + const [viewing, setViewing] = useState(null); + const [, setTick] = useState(0); // for elapsed time re-render + + // ── Refs ── + const wsRef = useRef(null); + const clientIdRef = useRef(''); + const localStreamRef = useRef(null); + const localVideoRef = useRef(null); + const remoteVideoRef = useRef(null); + /** Broadcaster: one PeerConnection per viewer */ + const peerConnectionsRef = useRef>(new Map()); + /** Viewer: single PeerConnection to broadcaster */ + const viewerPcRef = useRef(null); + const reconnectTimerRef = useRef | null>(null); + const reconnectDelayRef = useRef(1000); + + // ── 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]); + + // ── WebSocket connect ── + 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; + + ws.onopen = () => { + reconnectDelayRef.current = 1000; + }; + + ws.onmessage = (ev) => { + let msg: any; + try { msg = JSON.parse(ev.data); } catch { return; } + handleWsMessage(msg); + }; + + ws.onclose = () => { + wsRef.current = null; + // Auto-reconnect if broadcasting or viewing + if (isBroadcasting || viewing) { + reconnectTimerRef.current = setTimeout(() => { + reconnectDelayRef.current = Math.min(reconnectDelayRef.current * 2, 10000); + connectWs(); + }, reconnectDelayRef.current); + } + }; + + ws.onerror = () => { + ws.close(); + }; + }, [isBroadcasting, viewing]); + + // ── WS message handler ── + const handleWsMessage = useCallback((msg: any) => { + switch (msg.type) { + case 'welcome': + clientIdRef.current = msg.clientId; + if (msg.streams) setStreams(msg.streams); + break; + + case 'broadcast_started': + setMyStreamId(msg.streamId); + setIsBroadcasting(true); + setStarting(false); + break; + + case 'stream_available': + // SSE will update streams list; this is just a hint + break; + + case 'stream_ended': + if (viewing?.streamId === msg.streamId) { + cleanupViewer(); + setViewing(null); + } + break; + + // ── Broadcaster: viewer joined → create offer ── + case 'viewer_joined': { + const viewerId = msg.viewerId; + 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); + } + } + + pc.onicecandidate = (ev) => { + if (ev.candidate) { + wsSend({ type: 'ice_candidate', targetId: viewerId, candidate: ev.candidate.toJSON() }); + } + }; + + pc.createOffer() + .then(offer => pc.setLocalDescription(offer)) + .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); + } + break; + } + + // ── Viewer: received offer from broadcaster ── + case 'offer': { + const pc = new RTCPeerConnection(RTC_CONFIG); + viewerPcRef.current = pc; + + pc.ontrack = (ev) => { + 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: msg.fromId, candidate: ev.candidate.toJSON() }); + } + }; + + 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)) + .then(() => pc.createAnswer()) + .then(answer => pc.setLocalDescription(answer)) + .then(() => { + wsSend({ type: 'answer', targetId: msg.fromId, sdp: pc.localDescription }); + }) + .catch(console.error); + break; + } + + // ── Broadcaster: received answer from viewer ── + case 'answer': { + const pc = peerConnectionsRef.current.get(msg.fromId); + if (pc) { + pc.setRemoteDescription(new RTCSessionDescription(msg.sdp)).catch(console.error); + } + break; + } + + // ── ICE candidate relay ── + case 'ice_candidate': { + const pc = isBroadcasting + ? peerConnectionsRef.current.get(msg.fromId) + : viewerPcRef.current; + if (pc && msg.candidate) { + pc.addIceCandidate(new RTCIceCandidate(msg.candidate)).catch(() => {}); + } + break; + } + + case 'error': + setError(msg.message); + setStarting(false); + break; + } + }, [isBroadcasting, viewing]); + + // ── Send via WS ── + const wsSend = (data: Record) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify(data)); + } + }; + + // ── Start broadcasting ── + const startBroadcast = useCallback(async () => { + if (!userName.trim()) { setError('Bitte gib einen Namen ein.'); return; } + + // Check browser support + if (!navigator.mediaDevices?.getDisplayMedia) { + setError('Dein Browser unterstützt keine Bildschirmfreigabe.'); + return; + } + + setError(null); + setStarting(true); + + try { + const stream = await navigator.mediaDevices.getDisplayMedia({ + video: true, + audio: true, + }); + localStreamRef.current = stream; + + // Show local preview + 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(); + + // Wait for WS to open, then send start_broadcast + const waitForWs = () => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsSend({ type: 'start_broadcast', name: userName.trim(), title: streamTitle.trim() || 'Screen Share' }); + } else { + setTimeout(waitForWs, 100); + } + }; + waitForWs(); + } catch (e: any) { + setStarting(false); + if (e.name === 'NotAllowedError') { + setError('Bildschirmfreigabe wurde abgelehnt.'); + } else { + setError(`Fehler: ${e.message}`); + } + } + }, [userName, streamTitle, connectWs]); + + // ── Stop broadcasting ── + const stopBroadcast = useCallback(() => { + wsSend({ type: 'stop_broadcast' }); + + // Stop all tracks + localStreamRef.current?.getTracks().forEach(t => t.stop()); + localStreamRef.current = null; + if (localVideoRef.current) localVideoRef.current.srcObject = null; + + // Close all peer connections + for (const pc of peerConnectionsRef.current.values()) pc.close(); + peerConnectionsRef.current.clear(); + + setIsBroadcasting(false); + setMyStreamId(null); + }, []); + + // ── Join as viewer ── + const joinStream = 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]); + + // ── Leave viewer ── + const cleanupViewer = useCallback(() => { + if (viewerPcRef.current) { + viewerPcRef.current.close(); + viewerPcRef.current = null; + } + if (remoteVideoRef.current) remoteVideoRef.current.srcObject = null; + }, []); + + const leaveViewing = useCallback(() => { + wsSend({ type: 'leave_viewer' }); + cleanupViewer(); + setViewing(null); + }, [cleanupViewer]); + + // ── 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); + }; + }, []); + + // ── Render ── + + // Fullscreen viewer overlay + if (viewing) { + const stream = streams.find(s => s.id === viewing.streamId); + return ( +
+
+
+ LIVE +
+
{stream?.title || 'Stream'}
+
+ {stream?.broadcasterName || '...'} {stream ? `\u00B7 ${stream.viewerCount} Zuschauer` : ''} +
+
+
+ +
+
+ {viewing.phase === 'connecting' ? ( +
+
+ Verbindung wird hergestellt... +
+ ) : viewing.phase === 'error' ? ( +
+ {viewing.error || 'Verbindungsfehler'} + +
+ ) : null} +
+
+ ); + } + + return ( +
+ {/* Error */} + {error && ( +
+ {error} + +
+ )} + + {/* Top bar: name, title, start/stop */} +
+ setUserName(e.target.value)} + disabled={isBroadcasting} + /> + setStreamTitle(e.target.value)} + disabled={isBroadcasting} + /> + {isBroadcasting ? ( + + ) : ( + + )} +
+ + {/* Grid */} + {streams.length === 0 && !isBroadcasting ? ( +
+
{'\u{1F4FA}'}
+

Keine aktiven Streams

+

Starte einen Stream, um deinen Bildschirm zu teilen.

+
+ ) : ( +
+ {/* Own broadcast tile (with local preview) */} + {isBroadcasting && ( +
+
+
+
+
+
{userName} (Du)
+
{streamTitle}
+
+ + {myStreamId && streams.find(s => s.id === myStreamId)?.startedAt + ? formatElapsed(streams.find(s => s.id === myStreamId)!.startedAt) + : '0:00'} + +
+
+ )} + + {/* Other streams */} + {streams + .filter(s => s.id !== myStreamId) + .map(s => ( +
joinStream(s.id)}> +
+ {'\u{1F5A5}\uFE0F'} + LIVE + {'\u{1F465}'} {s.viewerCount} +
+
+
+
{s.broadcasterName}
+
{s.title}
+
+ {formatElapsed(s.startedAt)} + +
+
+ ))} +
+ )} +
+ ); +} diff --git a/web/src/plugins/streaming/streaming.css b/web/src/plugins/streaming/streaming.css new file mode 100644 index 0000000..204c36c --- /dev/null +++ b/web/src/plugins/streaming/streaming.css @@ -0,0 +1,326 @@ +/* ── Streaming Plugin ── */ + +.stream-container { + height: 100%; + overflow-y: auto; + padding: 16px; +} + +/* ── Top Bar ── */ +.stream-topbar { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 16px; + flex-wrap: wrap; +} + +.stream-input { + padding: 10px 14px; + border: 1px solid var(--bg-tertiary); + border-radius: var(--radius); + background: var(--bg-secondary); + color: var(--text-normal); + font-size: 14px; + outline: none; + transition: border-color var(--transition); + min-width: 0; +} +.stream-input:focus { border-color: var(--accent); } +.stream-input::placeholder { color: var(--text-faint); } +.stream-input-name { width: 150px; } +.stream-input-title { flex: 1; min-width: 180px; } + +.stream-btn { + padding: 10px 20px; + border: none; + border-radius: var(--radius); + background: var(--accent); + color: #fff; + font-weight: 600; + font-size: 14px; + cursor: pointer; + transition: background var(--transition); + white-space: nowrap; + display: flex; + align-items: center; + gap: 6px; +} +.stream-btn:hover { background: var(--accent-hover); } +.stream-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.stream-btn-stop { + background: var(--danger); +} +.stream-btn-stop:hover { background: #c93b3e; } + +/* ── Grid ── */ +.stream-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 16px; +} + +/* ── Tile (Kachel) ── */ +.stream-tile { + background: var(--bg-secondary); + border-radius: var(--radius-lg); + overflow: hidden; + cursor: pointer; + transition: transform var(--transition), box-shadow var(--transition); + position: relative; +} +.stream-tile:hover { + transform: translateY(-2px); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4); +} +.stream-tile.own { + border: 2px solid var(--accent); +} + +/* Preview area (16:9) */ +.stream-tile-preview { + position: relative; + width: 100%; + padding-top: 56.25%; /* 16:9 */ + background: var(--bg-deep); + overflow: hidden; +} +.stream-tile-preview video { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: cover; +} +.stream-tile-preview .stream-tile-icon { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 48px; + opacity: 0.3; +} + +/* LIVE badge */ +.stream-live-badge { + position: absolute; + top: 8px; + left: 8px; + background: var(--danger); + color: #fff; + font-size: 11px; + font-weight: 700; + padding: 2px 8px; + border-radius: 4px; + letter-spacing: 0.5px; + display: flex; + align-items: center; + gap: 4px; +} +.stream-live-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: #fff; + animation: stream-pulse 1.5s ease-in-out infinite; +} +@keyframes stream-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} + +/* Viewer count on tile */ +.stream-tile-viewers { + position: absolute; + top: 8px; + right: 8px; + background: rgba(0, 0, 0, 0.6); + color: #fff; + font-size: 12px; + padding: 2px 8px; + border-radius: 4px; + display: flex; + align-items: center; + gap: 4px; +} + +/* Info bar below preview */ +.stream-tile-info { + padding: 10px 12px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.stream-tile-meta { + min-width: 0; + flex: 1; +} +.stream-tile-name { + font-size: 14px; + font-weight: 600; + color: var(--text-normal); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.stream-tile-title { + font-size: 12px; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.stream-tile-time { + font-size: 12px; + color: var(--text-faint); + white-space: nowrap; +} + +/* Three-dot menu */ +.stream-tile-menu { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + padding: 4px; + font-size: 18px; + line-height: 1; + border-radius: 4px; + transition: background var(--transition); +} +.stream-tile-menu:hover { + background: var(--bg-tertiary); + color: var(--text-normal); +} + +/* ── Fullscreen Viewer ── */ +.stream-viewer-overlay { + position: fixed; + inset: 0; + z-index: 1000; + background: #000; + display: flex; + flex-direction: column; +} +.stream-viewer-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: rgba(0, 0, 0, 0.8); + color: #fff; + z-index: 1; +} +.stream-viewer-header-left { + display: flex; + align-items: center; + gap: 12px; +} +.stream-viewer-title { + font-weight: 600; + font-size: 16px; +} +.stream-viewer-subtitle { + font-size: 13px; + color: var(--text-muted); +} +.stream-viewer-close { + background: rgba(255, 255, 255, 0.1); + border: none; + color: #fff; + padding: 8px 16px; + border-radius: var(--radius); + cursor: pointer; + font-size: 14px; + transition: background var(--transition); +} +.stream-viewer-close:hover { + background: rgba(255, 255, 255, 0.2); +} +.stream-viewer-video { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} +.stream-viewer-video video { + max-width: 100%; + max-height: 100%; + width: 100%; + height: 100%; + object-fit: contain; +} +.stream-viewer-connecting { + color: var(--text-muted); + font-size: 16px; + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; +} +.stream-viewer-spinner { + width: 32px; + height: 32px; + border: 3px solid var(--bg-tertiary); + border-top-color: var(--accent); + border-radius: 50%; + animation: stream-spin 0.8s linear infinite; +} +@keyframes stream-spin { + to { transform: rotate(360deg); } +} + +/* ── Empty state ── */ +.stream-empty { + text-align: center; + padding: 60px 20px; + color: var(--text-muted); +} +.stream-empty-icon { + font-size: 48px; + margin-bottom: 12px; + opacity: 0.4; +} +.stream-empty h3 { + font-size: 18px; + font-weight: 600; + color: var(--text-normal); + margin-bottom: 6px; +} +.stream-empty p { + font-size: 14px; +} + +/* ── Error ── */ +.stream-error { + background: rgba(237, 66, 69, 0.12); + color: var(--danger); + padding: 10px 14px; + border-radius: var(--radius); + font-size: 14px; + margin-bottom: 12px; + display: flex; + align-items: center; + gap: 8px; +} +.stream-error-dismiss { + background: none; + border: none; + color: var(--danger); + cursor: pointer; + margin-left: auto; + font-size: 16px; + padding: 0 4px; +} + +/* ── Broadcaster local preview tile ── */ +.stream-tile.broadcasting .stream-tile-preview { + border: 2px solid var(--danger); + border-bottom: none; +}