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:
parent
dacfde4328
commit
4aed4e70ab
3 changed files with 191 additions and 15 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue