fix(streaming): reliable disconnect + mandatory stream password

Disconnect:
- Server-side heartbeat ping/pong every 10s with 25s timeout
- Detects and cleans up dead connections (browser closed, network lost)
- ws.terminate() on heartbeat timeout triggers handleDisconnect

Password:
- Stream password is mandatory (server rejects start_broadcast without)
- Password stored server-side, never sent to clients
- Viewers must enter password via modal before joining
- Lock icon on tiles, WRONG_PASSWORD error shown in modal

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-03-07 01:00:48 +01:00
parent dacfde4328
commit 4aed4e70ab
3 changed files with 191 additions and 15 deletions

View file

@ -9,6 +9,15 @@ interface StreamInfo {
title: string;
startedAt: string;
viewerCount: number;
hasPassword: boolean;
}
interface JoinModal {
streamId: string;
streamTitle: string;
broadcasterName: string;
password: string;
error: string | null;
}
interface ViewState {
@ -41,7 +50,9 @@ export default function StreamingTab({ data }: { data: any }) {
const [streams, setStreams] = useState<StreamInfo[]>([]);
const [userName, setUserName] = useState(() => localStorage.getItem('streaming_name') || '');
const [streamTitle, setStreamTitle] = useState('Screen Share');
const [streamPassword, setStreamPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [joinModal, setJoinModal] = useState<JoinModal | null>(null);
const [myStreamId, setMyStreamId] = useState<string | null>(null);
const [isBroadcasting, setIsBroadcasting] = useState(false);
const [starting, setStarting] = useState(false);
@ -234,7 +245,12 @@ export default function StreamingTab({ data }: { data: any }) {
}
case 'error':
setError(msg.message);
if (msg.code === 'WRONG_PASSWORD') {
// Show error inside join modal
setJoinModal(prev => prev ? { ...prev, error: msg.message } : prev);
} else {
setError(msg.message);
}
setStarting(false);
break;
}
@ -278,6 +294,7 @@ export default function StreamingTab({ data }: { data: any }) {
// ── 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.');
@ -309,7 +326,7 @@ export default function StreamingTab({ data }: { data: any }) {
const waitForWs = () => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsSend({ type: 'start_broadcast', name: userName.trim(), title: streamTitle.trim() || 'Screen Share' });
wsSend({ type: 'start_broadcast', name: userName.trim(), title: streamTitle.trim() || 'Screen Share', password: streamPassword.trim() });
} else {
setTimeout(waitForWs, 100);
}
@ -323,7 +340,7 @@ export default function StreamingTab({ data }: { data: any }) {
setError(`Fehler: ${e.message}`);
}
}
}, [userName, streamTitle, connectWs, wsSend]);
}, [userName, streamTitle, streamPassword, connectWs, wsSend]);
// ── Stop broadcasting ──
const stopBroadcast = useCallback(() => {
@ -339,23 +356,41 @@ export default function StreamingTab({ data }: { data: any }) {
setIsBroadcasting(false);
isBroadcastingRef.current = false;
setMyStreamId(null);
setStreamPassword('');
}, [wsSend]);
// ── Join as viewer ──
const joinStream = useCallback((streamId: string) => {
// ── Join as viewer (opens password modal first) ──
const openJoinModal = useCallback((s: StreamInfo) => {
setJoinModal({
streamId: s.id,
streamTitle: s.title,
broadcasterName: s.broadcasterName,
password: '',
error: null,
});
}, []);
const submitJoinModal = useCallback(() => {
if (!joinModal) return;
if (!joinModal.password.trim()) {
setJoinModal(prev => prev ? { ...prev, error: 'Passwort eingeben.' } : prev);
return;
}
const { streamId, password } = joinModal;
setJoinModal(null);
setError(null);
setViewing({ streamId, phase: 'connecting' });
connectWs();
const waitForWs = () => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsSend({ type: 'join_viewer', name: userName.trim() || 'Viewer', streamId });
wsSend({ type: 'join_viewer', name: userName.trim() || 'Viewer', streamId, password: password.trim() });
} else {
setTimeout(waitForWs, 100);
}
};
waitForWs();
}, [userName, connectWs, wsSend]);
}, [joinModal, userName, connectWs, wsSend]);
// ── Leave viewer ──
const leaveViewing = useCallback(() => {
@ -438,6 +473,14 @@ export default function StreamingTab({ data }: { data: any }) {
onChange={e => setStreamTitle(e.target.value)}
disabled={isBroadcasting}
/>
<input
className="stream-input stream-input-password"
type="password"
placeholder="Passwort"
value={streamPassword}
onChange={e => setStreamPassword(e.target.value)}
disabled={isBroadcasting}
/>
{isBroadcasting ? (
<button className="stream-btn stream-btn-stop" onClick={stopBroadcast}>
{'\u23F9'} Stream beenden
@ -486,11 +529,12 @@ export default function StreamingTab({ data }: { data: any }) {
{streams
.filter(s => s.id !== myStreamId)
.map(s => (
<div key={s.id} className="stream-tile" onClick={() => joinStream(s.id)}>
<div key={s.id} className="stream-tile" onClick={() => openJoinModal(s)}>
<div className="stream-tile-preview">
<span className="stream-tile-icon">{'\u{1F5A5}\uFE0F'}</span>
<span className="stream-live-badge"><span className="stream-live-dot" /> LIVE</span>
<span className="stream-tile-viewers">{'\u{1F465}'} {s.viewerCount}</span>
{s.hasPassword && <span className="stream-tile-lock">{'\u{1F512}'}</span>}
</div>
<div className="stream-tile-info">
<div className="stream-tile-meta">
@ -504,6 +548,30 @@ export default function StreamingTab({ data }: { data: any }) {
))}
</div>
)}
{/* Password join modal */}
{joinModal && (
<div className="stream-pw-overlay" onClick={() => setJoinModal(null)}>
<div className="stream-pw-modal" onClick={e => e.stopPropagation()}>
<h3>{joinModal.broadcasterName}</h3>
<p>{joinModal.streamTitle}</p>
{joinModal.error && <div className="stream-pw-modal-error">{joinModal.error}</div>}
<input
className="stream-input"
type="password"
placeholder="Stream-Passwort"
value={joinModal.password}
onChange={e => setJoinModal(prev => prev ? { ...prev, password: e.target.value, error: null } : prev)}
onKeyDown={e => { if (e.key === 'Enter') submitJoinModal(); }}
autoFocus
/>
<div className="stream-pw-actions">
<button className="stream-pw-cancel" onClick={() => setJoinModal(null)}>Abbrechen</button>
<button className="stream-btn" onClick={submitJoinModal}>Beitreten</button>
</div>
</div>
</div>
)}
</div>
);
}