Skip to content

Commit 9c057e0

Browse files
committed
split up app.tsx in components and hooks
Signed-off-by: sarib <saribstudent@gmail.com>
1 parent 56aed2c commit 9c057e0

File tree

2 files changed

+360
-0
lines changed

2 files changed

+360
-0
lines changed
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import React, { useState } from 'react';
2+
import { useWebRTCPlayer } from '../hooks/useWebRTCPlayer';
3+
4+
const Play = ({ size = 24, fill = 'white' }) => (
5+
<svg width={size} height={size} viewBox="0 0 24 24" fill={fill}>
6+
<path d="M8 5v14l11-7z"/>
7+
</svg>
8+
);
9+
10+
const Pause = ({ size = 24, fill = 'white' }) => (
11+
<svg width={size} height={size} viewBox="0 0 24 24" fill={fill}>
12+
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
13+
</svg>
14+
);
15+
16+
const Maximize = ({ size = 20 }) => (
17+
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
18+
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/>
19+
</svg>
20+
);
21+
22+
export interface WebRTCStreamPlayerProps {
23+
signalingEndpoint?: string;
24+
className?: string;
25+
style?: React.CSSProperties;
26+
muted?: boolean;
27+
autoPlay?: boolean;
28+
}
29+
30+
export default function WebRTCStreamPlayer({
31+
signalingEndpoint,
32+
className,
33+
style,
34+
muted = true,
35+
autoPlay = false,
36+
}: WebRTCStreamPlayerProps) {
37+
const {
38+
videoRef,
39+
connectionState,
40+
errorReason,
41+
isPaused,
42+
togglePlayPause,
43+
disconnect,
44+
enterFullscreen,
45+
} = useWebRTCPlayer({ signalingEndpoint, autoPlay });
46+
47+
const [showControls, setShowControls] = useState(true);
48+
49+
return (
50+
<div className={className} style={{ display: 'flex', flexDirection: 'column', gap: 16, ...style }}>
51+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 12 }}>
52+
<span style={{
53+
fontSize: 14,
54+
opacity: 0.8,
55+
display: 'flex',
56+
alignItems: 'center',
57+
gap: 8
58+
}}>
59+
<span style={{
60+
width: 8,
61+
height: 8,
62+
borderRadius: '50%',
63+
backgroundColor: connectionState === 'connected' ? '#0f0' : connectionState === 'connecting' ? '#ff0' : connectionState === 'error' ? '#f00' : '#666'
64+
}} />
65+
{connectionState === 'idle' && 'Idle'}
66+
{connectionState === 'connecting' && 'Connecting…'}
67+
{connectionState === 'connected' && (isPaused ? 'Paused' : 'Connected')}
68+
{connectionState === 'error' && `Error: ${errorReason}`}
69+
</span>
70+
<button
71+
onClick={disconnect}
72+
disabled={connectionState === 'idle'}
73+
style={{
74+
padding: '10px 24px',
75+
backgroundColor: connectionState === 'idle' ? '#666' : '#dc2626',
76+
color: 'white',
77+
border: 'none',
78+
borderRadius: 6,
79+
fontSize: 15,
80+
fontWeight: 600,
81+
cursor: connectionState === 'idle' ? 'not-allowed' : 'pointer',
82+
opacity: connectionState === 'idle' ? 0.5 : 1,
83+
transition: 'all 0.2s'
84+
}}
85+
onMouseOver={(e) => {
86+
if (connectionState !== 'idle') {
87+
e.currentTarget.style.backgroundColor = '#b91c1c';
88+
}
89+
}}
90+
onMouseOut={(e) => {
91+
if (connectionState !== 'idle') {
92+
e.currentTarget.style.backgroundColor = '#dc2626';
93+
}
94+
}}
95+
>
96+
Disconnect
97+
</button>
98+
</div>
99+
100+
<div
101+
style={{
102+
width: '100%',
103+
maxWidth: '100%',
104+
position: 'relative'
105+
}}
106+
onMouseEnter={() => setShowControls(true)}
107+
onMouseLeave={() => setShowControls(false)}
108+
>
109+
<video
110+
ref={videoRef}
111+
autoPlay={autoPlay}
112+
playsInline
113+
muted={muted}
114+
style={{
115+
width: '100%',
116+
maxWidth: '100%',
117+
aspectRatio: '16 / 9',
118+
background: '#000',
119+
borderRadius: 8,
120+
display: 'block'
121+
}}
122+
/>
123+
124+
{/* Controls overlay */}
125+
<div style={{
126+
position: 'absolute',
127+
bottom: 0,
128+
left: 0,
129+
right: 0,
130+
padding: '12px',
131+
background: 'linear-gradient(to top, rgba(0,0,0,0.7) 0%, transparent 100%)',
132+
opacity: showControls ? 1 : 0,
133+
transition: 'opacity 0.3s',
134+
display: 'flex',
135+
alignItems: 'center',
136+
justifyContent: 'space-between',
137+
borderRadius: '0 0 8px 8px'
138+
}}>
139+
{/* Left side - Play/Pause */}
140+
<button
141+
onClick={togglePlayPause}
142+
style={{
143+
background: 'transparent',
144+
border: 'none',
145+
color: 'white',
146+
cursor: 'pointer',
147+
padding: 8,
148+
display: 'flex',
149+
alignItems: 'center',
150+
justifyContent: 'center',
151+
borderRadius: 4,
152+
transition: 'background 0.2s'
153+
}}
154+
onMouseOver={(e) => e.currentTarget.style.background = 'rgba(255,255,255,0.2)'}
155+
onMouseOut={(e) => e.currentTarget.style.background = 'transparent'}
156+
>
157+
{connectionState !== 'connected' || isPaused ? <Play size={24} fill="white" /> : <Pause size={24} fill="white" />}
158+
</button>
159+
160+
{/* Right side - Fullscreen */}
161+
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
162+
<button
163+
onClick={enterFullscreen}
164+
style={{
165+
background: 'transparent',
166+
border: 'none',
167+
color: 'white',
168+
cursor: 'pointer',
169+
padding: 8,
170+
display: 'flex',
171+
alignItems: 'center',
172+
justifyContent: 'center',
173+
borderRadius: 4,
174+
transition: 'background 0.2s'
175+
}}
176+
onMouseOver={(e) => e.currentTarget.style.background = 'rgba(255,255,255,0.2)'}
177+
onMouseOut={(e) => e.currentTarget.style.background = 'transparent'}
178+
>
179+
<Maximize size={20} />
180+
</button>
181+
</div>
182+
</div>
183+
</div>
184+
</div>
185+
);
186+
}
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2+
3+
export type ConnectionState = 'idle' | 'connecting' | 'connected' | 'error';
4+
5+
function normalizeOfferUrl(raw?: string): string {
6+
const fallback = 'http://localhost:8001/offer';
7+
if (!raw) return fallback;
8+
try {
9+
const url = new URL(raw);
10+
const path = url.pathname.replace(/\/+$/, '');
11+
url.pathname = path.endsWith('/offer') ? path : `${path}/offer`;
12+
return url.toString();
13+
} catch {
14+
try {
15+
const withScheme = /^https?:\/\//.test(raw) ? raw : `http://${raw}`;
16+
const url = new URL(withScheme);
17+
const path = url.pathname.replace(/\/+$/, '');
18+
url.pathname = path.endsWith('/offer') ? path : `${path}/offer`;
19+
return url.toString();
20+
} catch {
21+
return fallback;
22+
}
23+
}
24+
}
25+
26+
export interface UseWebRTCPlayerOptions {
27+
signalingEndpoint?: string;
28+
autoPlay?: boolean;
29+
}
30+
31+
export interface UseWebRTCPlayerResult {
32+
videoRef: React.RefObject<HTMLVideoElement>;
33+
connectionState: ConnectionState;
34+
errorReason: string;
35+
isPaused: boolean;
36+
connect: () => Promise<void>;
37+
disconnect: () => void;
38+
togglePlayPause: () => Promise<void>;
39+
enterFullscreen: () => void;
40+
}
41+
42+
export function useWebRTCPlayer({ signalingEndpoint, autoPlay = false }: UseWebRTCPlayerOptions): UseWebRTCPlayerResult {
43+
const offerUrl = useMemo(() => {
44+
const envUrl = (import.meta as any)?.env?.VITE_BACKEND_URL as string | undefined;
45+
const base = signalingEndpoint ?? envUrl ?? 'http://localhost:8001';
46+
return normalizeOfferUrl(base);
47+
}, [signalingEndpoint]);
48+
49+
const videoRef = useRef<HTMLVideoElement | null>(null);
50+
const pcRef = useRef<RTCPeerConnection | null>(null);
51+
52+
const [connectionState, setConnectionState] = useState<ConnectionState>('idle');
53+
const [errorReason, setErrorReason] = useState('');
54+
const [isPaused, setIsPaused] = useState(false);
55+
56+
const connect = useCallback(async () => {
57+
if (pcRef.current) return;
58+
setErrorReason('');
59+
setConnectionState('connecting');
60+
61+
const pc = new RTCPeerConnection({ iceServers: [{ urls: ['stun:stun.l.google.com:19302'] }] });
62+
pcRef.current = pc;
63+
64+
pc.addTransceiver('video', { direction: 'recvonly' });
65+
66+
pc.ontrack = (e) => {
67+
const [stream] = e.streams;
68+
if (!videoRef.current) return;
69+
videoRef.current.srcObject = stream;
70+
videoRef.current.onloadedmetadata = () => {
71+
if (autoPlay) {
72+
videoRef.current?.play().catch(() => {});
73+
}
74+
};
75+
};
76+
77+
try {
78+
const offer = await pc.createOffer();
79+
await pc.setLocalDescription(offer);
80+
81+
await new Promise<void>((resolve) => {
82+
if (pc.iceGatheringState === 'complete') return resolve();
83+
const handler = () => {
84+
if (pc.iceGatheringState === 'complete') {
85+
pc.removeEventListener('icegatheringstatechange', handler);
86+
resolve();
87+
}
88+
};
89+
pc.addEventListener('icegatheringstatechange', handler);
90+
});
91+
92+
const res = await fetch(offerUrl, {
93+
method: 'POST',
94+
headers: { 'Content-Type': 'application/json' },
95+
body: JSON.stringify({ sdp: pc.localDescription?.sdp ?? '', type: 'offer' }),
96+
});
97+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
98+
const answer = await res.json();
99+
await pc.setRemoteDescription(new RTCSessionDescription(answer));
100+
setConnectionState('connected');
101+
setIsPaused(false);
102+
} catch (err) {
103+
setConnectionState('error');
104+
setErrorReason(String(err));
105+
try {
106+
pc.close();
107+
} catch {}
108+
pcRef.current = null;
109+
}
110+
}, [offerUrl, autoPlay]);
111+
112+
const disconnect = useCallback(() => {
113+
const pc = pcRef.current;
114+
if (pc) {
115+
try {
116+
pc.getReceivers().forEach((r) => r.track && (r.track.enabled = false));
117+
pc.close();
118+
} catch {}
119+
}
120+
pcRef.current = null;
121+
if (videoRef.current) {
122+
try {
123+
videoRef.current.pause();
124+
(videoRef.current as any).srcObject = null;
125+
} catch {}
126+
}
127+
setIsPaused(false);
128+
setConnectionState('idle');
129+
}, []);
130+
131+
const togglePlayPause = useCallback(async () => {
132+
if (connectionState !== 'connected') {
133+
await connect();
134+
return;
135+
}
136+
if (!videoRef.current) return;
137+
if (isPaused) {
138+
try {
139+
pcRef.current?.getReceivers().forEach((r) => r.track && (r.track.enabled = true));
140+
} catch {}
141+
await videoRef.current.play().catch(() => {});
142+
setIsPaused(false);
143+
} else {
144+
try {
145+
pcRef.current?.getReceivers().forEach((r) => r.track && (r.track.enabled = false));
146+
} catch {}
147+
videoRef.current.pause();
148+
setIsPaused(true);
149+
}
150+
}, [connectionState, isPaused, connect]);
151+
152+
const enterFullscreen = useCallback(() => {
153+
const el = videoRef.current?.parentElement ?? videoRef.current;
154+
if (!el) return;
155+
const anyEl = el as any;
156+
const req = anyEl.requestFullscreen || anyEl.webkitRequestFullscreen || anyEl.msRequestFullscreen;
157+
if (req) req.call(anyEl);
158+
}, []);
159+
160+
useEffect(() => {
161+
return () => disconnect();
162+
}, [disconnect]);
163+
164+
return {
165+
videoRef,
166+
connectionState,
167+
errorReason,
168+
isPaused,
169+
connect,
170+
disconnect,
171+
togglePlayPause,
172+
enterFullscreen,
173+
};
174+
}

0 commit comments

Comments
 (0)