/ concept-collection / commonroom
Sign in
concept-collection / commonroom
commonroom / src / App.tsx
724 lines · 18.3 KBBlameHistoryRaw
1import {useEffect, useRef, useState} from 'react'
2import {MAX_PARTICIPANTS, type ParticipantInfo} from './p2p/network'
3import {VIDEO_QUALITIES, type VideoQuality} from './p2p/settings'
4import {useNetwork} from './useNetwork'
6// Screen capture is desktop-only in practice; hide the button where the API
7// doesn't exist (most mobile browsers).
8const canShareScreen =
9 typeof navigator.mediaDevices?.getDisplayMedia === 'function'
11const initialRoomFromHash = (): string => {
12 try {
13 return decodeURIComponent(location.hash.slice(1)).replace(/\s/g, '')
14 } catch {
15 return ''
16 }
19// ---- styles ---------------------------------------------------------------
21const lightBtn: React.CSSProperties = {
22 padding: '0.45rem 1.1rem',
23 borderRadius: 6,
24 border: '1px solid #888',
25 background: '#fff',
26 cursor: 'pointer',
27 fontSize: '1rem'
30const primaryBtn: React.CSSProperties = {
31 ...lightBtn,
32 background: '#1a7f37',
33 borderColor: '#1a7f37',
34 color: '#fff'
37const disabledStyle: React.CSSProperties = {
38 opacity: 0.4,
39 cursor: 'not-allowed'
42const inputStyle: React.CSSProperties = {
43 padding: '0.5rem',
44 fontSize: '1rem',
45 borderRadius: 6,
46 border: '1px solid #bbb',
47 width: '100%',
48 boxSizing: 'border-box'
51// Dark in-room controls.
52const darkBtn: React.CSSProperties = {
53 padding: '0.5rem 0.9rem',
54 borderRadius: 8,
55 border: '1px solid #555',
56 background: '#2a2a2a',
57 color: '#eee',
58 cursor: 'pointer',
59 fontSize: '0.95rem',
60 display: 'inline-flex',
61 alignItems: 'center',
62 gap: '0.4rem'
65// Square icon-only buttons for the control bar. Their meaning is carried by
66// the icon plus a title tooltip and aria-label.
67const iconBtn: React.CSSProperties = {
68 ...darkBtn,
69 padding: '0.65rem',
70 justifyContent: 'center'
73// A mute button while muted — red, the universal "you are muted" signal.
74const mutedIconBtn: React.CSSProperties = {
75 ...iconBtn,
76 background: '#c62828',
77 borderColor: '#c62828',
78 color: '#fff'
81// The screen-share button while sharing.
82const sharingIconBtn: React.CSSProperties = {
83 ...iconBtn,
84 background: '#1a7f37',
85 borderColor: '#1a7f37',
86 color: '#fff'
89const leaveBtn: React.CSSProperties = {
90 ...darkBtn,
91 background: '#c62828',
92 borderColor: '#c62828',
93 color: '#fff'
96// Chip overlaid on a tile (name label, mute badges).
97const tileChip: React.CSSProperties = {
98 background: 'rgba(0, 0, 0, 0.65)',
99 color: '#fff',
100 borderRadius: 6,
101 padding: '0.2rem 0.5rem',
102 fontSize: '0.85rem',
103 display: 'inline-flex',
104 alignItems: 'center',
105 gap: '0.3rem',
106 maxWidth: '90%'
109// ---- icons (stroke-style paths from Feather icons, MIT) --------------------
111const ICONS = {
112 mic: (
113 <>
114 <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
115 <path d="M19 10v2a7 7 0 0 1-14 0v-2" />
116 <path d="M12 19v4" />
117 <path d="M8 23h8" />
118 </>
119 ),
120 micOff: (
121 <>
122 <path d="M1 1l22 22" />
123 <path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6" />
124 <path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23" />
125 <path d="M12 19v4" />
126 <path d="M8 23h8" />
127 </>
128 ),
129 video: (
130 <>
131 <path d="M23 7l-7 5 7 5V7z" />
132 <rect x="1" y="5" width="15" height="14" rx="2" />
133 </>
134 ),
135 videoOff: (
136 <>
137 <path d="M16 16v1a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2m5.66 0H14a2 2 0 0 1 2 2v3.34l1 1L23 7v10" />
138 <path d="M1 1l22 22" />
139 </>
140 ),
141 monitor: (
142 <>
143 <rect x="2" y="3" width="20" height="14" rx="2" />
144 <path d="M8 21h8" />
145 <path d="M12 17v4" />
146 </>
147 ),
148 link: (
149 <>
150 <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
151 <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
152 </>
153 ),
154 logout: (
155 <>
156 <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
157 <path d="M16 17l5-5-5-5" />
158 <path d="M21 12H9" />
159 </>
160 )
161} as const
163function Icon({
164 name,
165 size = 18,
166 style
167}: {
168 name: keyof typeof ICONS
169 size?: number
170 style?: React.CSSProperties
171}) {
172 return (
173 <svg
174 width={size}
175 height={size}
176 viewBox="0 0 24 24"
177 fill="none"
178 stroke="currentColor"
179 strokeWidth={2}
180 strokeLinecap="round"
181 strokeLinejoin="round"
182 style={{display: 'block', ...style}}
183 aria-hidden
184 >
185 {ICONS[name]}
186 </svg>
187 )
190// ---- video tile -------------------------------------------------------------
192function VideoView({
193 stream,
194 muted,
195 mirror
196}: {
197 stream: MediaStream | null
198 muted: boolean
199 mirror: boolean
200}) {
201 const ref = useRef<HTMLVideoElement>(null)
202 useEffect(() => {
203 if (ref.current && ref.current.srcObject !== stream) {
204 ref.current.srcObject = stream
205 }
206 }, [stream])
207 return (
208 <video
209 ref={ref}
210 autoPlay
211 playsInline
212 muted={muted}
213 style={{
214 position: 'absolute',
215 inset: 0,
216 width: '100%',
217 height: '100%',
218 objectFit: 'cover',
219 transform: mirror ? 'scaleX(-1)' : undefined
220 }}
221 />
222 )
225function Tile({
226 stream,
227 label,
228 isSelf,
229 mirror,
230 audioMuted,
231 videoMuted,
232 connecting
233}: {
234 stream: MediaStream | null
235 label: string
236 isSelf: boolean
237 mirror: boolean
238 audioMuted: boolean
239 videoMuted: boolean
240 connecting: boolean
241}) {
242 return (
243 <div
244 style={{
245 position: 'relative',
246 background: '#000',
247 borderRadius: 10,
248 overflow: 'hidden',
249 aspectRatio: '4 / 3',
250 border: isSelf ? '1px solid #444' : '1px solid #222'
251 }}
252 >
253 {/* The video element stays mounted even when their camera is off so the
254 audio keeps playing; the placeholder just covers it. */}
255 <VideoView stream={stream} muted={isSelf} mirror={mirror} />
256 {(videoMuted || connecting) && (
257 <div
258 style={{
259 position: 'absolute',
260 inset: 0,
261 background: '#222',
262 display: 'flex',
263 flexDirection: 'column',
264 alignItems: 'center',
265 justifyContent: 'center',
266 gap: '0.5rem'
267 }}
268 >
269 <div
270 style={{
271 width: 64,
272 height: 64,
273 borderRadius: '50%',
274 background: '#3a3a3a',
275 color: '#ddd',
276 display: 'flex',
277 alignItems: 'center',
278 justifyContent: 'center',
279 fontSize: '1.6rem',
280 fontFamily: 'sans-serif'
281 }}
282 >
283 {(label[0] ?? '?').toUpperCase()}
284 </div>
285 {connecting && (
286 <span style={{color: '#999', fontSize: '0.9rem'}}>Connecting…</span>
287 )}
288 </div>
289 )}
290 <div
291 style={{
292 position: 'absolute',
293 left: 8,
294 bottom: 8,
295 display: 'flex',
296 gap: '0.35rem',
297 alignItems: 'center'
298 }}
299 >
300 <span style={tileChip}>
301 <span
302 style={{
303 overflow: 'hidden',
304 textOverflow: 'ellipsis',
305 whiteSpace: 'nowrap'
306 }}
307 >
308 {label}
309 </span>
310 {audioMuted && <Icon name="micOff" size={13} />}
311 </span>
312 </div>
313 </div>
314 )
317// ---- landing ------------------------------------------------------------------
319function Landing({
320 notice,
321 initialName,
322 onDismissNotice,
323 onEnter
324}: {
325 notice: string | null
326 initialName: string
327 onDismissNotice: () => void
328 onEnter: (name: string, room: string) => void
329}) {
330 const [name, setName] = useState(initialName)
331 const [room, setRoom] = useState(initialRoomFromHash)
332 const canEnter = name.trim().length > 0 && room.length > 0
333 const submit = (e: React.FormEvent) => {
334 e.preventDefault()
335 if (canEnter) onEnter(name, room)
336 }
337 return (
338 <div
339 style={{
340 fontFamily: 'sans-serif',
341 maxWidth: 460,
342 margin: '3rem auto',
343 padding: '0 1rem'
344 }}
345 >
346 <h1 style={{marginBottom: '0.25rem'}}>CommonRoom</h1>
347 <p style={{color: '#666', marginTop: 0}}>
348 Group video calls with no server. Pick a room, share the link, and
349 talk — everything flows peer-to-peer.
350 </p>
352 {notice && (
353 <div
354 style={{
355 background: '#fff3cd',
356 border: '1px solid #e0c968',
357 borderRadius: 6,
358 padding: '0.5rem 0.75rem',
359 margin: '0.75rem 0',
360 display: 'flex',
361 justifyContent: 'space-between',
362 alignItems: 'center',
363 gap: '0.5rem'
364 }}
365 >
366 <span>{notice}</span>
367 <button style={lightBtn} onClick={onDismissNotice}>
368 OK
369 </button>
370 </div>
371 )}
373 <form onSubmit={submit} style={{marginTop: '1.5rem'}}>
374 <label style={{display: 'block', marginBottom: '1rem'}}>
375 <div style={{marginBottom: '0.3rem'}}>Your name</div>
376 <input
377 autoFocus={!initialName}
378 value={name}
379 onChange={e => setName(e.target.value)}
380 placeholder="e.g. Jeremy"
381 maxLength={40}
382 style={inputStyle}
383 />
384 </label>
385 <label style={{display: 'block', marginBottom: '1.25rem'}}>
386 <div style={{marginBottom: '0.3rem'}}>Room name</div>
387 <input
388 autoFocus={!!initialName}
389 value={room}
390 onChange={e => setRoom(e.target.value.replace(/\s/g, ''))}
391 placeholder="any-name-you-like (no spaces)"
392 maxLength={100}
393 style={inputStyle}
394 />
395 </label>
396 <button
397 type="submit"
398 style={canEnter ? primaryBtn : {...primaryBtn, ...disabledStyle}}
399 disabled={!canEnter}
400 >
401 Enter room
402 </button>
403 </form>
405 <p style={{color: '#888', fontSize: '0.85rem', marginTop: '1.5rem'}}>
406 Anyone who knows the room name can join — up to {MAX_PARTICIPANTS}{' '}
407 people per room. You enter with your microphone and camera off.
408 </p>
409 </div>
410 )
413// ---- room ----------------------------------------------------------------------
415function CopyLinkButton({compact}: {compact: boolean}) {
416 const [copied, setCopied] = useState(false)
417 const copy = async () => {
418 try {
419 await navigator.clipboard.writeText(location.href)
420 setCopied(true)
421 setTimeout(() => setCopied(false), 1500)
422 } catch {
423 /* clipboard unavailable; the address bar still has the link */
424 }
425 }
426 return (
427 <button
428 style={darkBtn}
429 onClick={() => void copy()}
430 title="Copy the room link to share"
431 >
432 <Icon name="link" size={15} />
433 {copied ? 'Copied!' : compact ? 'Copy link' : 'Copy room link'}
434 </button>
435 )
438function ParticipantTile({p}: {p: ParticipantInfo}) {
439 return (
440 <Tile
441 stream={p.stream}
442 label={p.name}
443 isSelf={false}
444 mirror={false}
445 audioMuted={p.audioMuted}
446 videoMuted={p.videoMuted}
447 connecting={!p.connected}
448 />
449 )
452export default function App() {
453 const {snapshot, network} = useNetwork()
454 const {
455 phase,
456 roomId,
457 name,
458 participants,
459 audioMuted,
460 videoMuted,
461 micAvailable,
462 camAvailable,
463 localStream,
464 screenStream,
465 settings,
466 notice
467 } = snapshot
469 if (phase === 'landing') {
470 return (
471 <Landing
472 notice={notice}
473 initialName={network.savedName}
474 onDismissNotice={() => network.dismissNotice()}
475 onEnter={(n, r) => void network.enterRoom(n, r)}
476 />
477 )
478 }
480 if (phase === 'joining') {
481 return (
482 <div
483 style={{
484 fontFamily: 'sans-serif',
485 maxWidth: 460,
486 margin: '3rem auto',
487 padding: '0 1rem'
488 }}
489 >
490 <h1 style={{marginBottom: '0.25rem'}}>CommonRoom</h1>
491 <p>
492 Joining <strong>#{roomId}</strong>… your browser may ask for camera
493 and microphone access (you'll still be muted until you turn them on).
494 </p>
495 <button style={lightBtn} onClick={() => network.leave()}>
496 Cancel
497 </button>
498 </div>
499 )
500 }
502 // ---- in-room (dark) ----
503 const sharing = screenStream !== null
504 const count = participants.length + 1
505 const alone = participants.length === 0
507 return (
508 <div
509 style={{
510 position: 'fixed',
511 inset: 0,
512 background: '#111',
513 color: '#eee',
514 fontFamily: 'sans-serif',
515 display: 'flex',
516 flexDirection: 'column'
517 }}
518 >
519 <header
520 style={{
521 display: 'flex',
522 alignItems: 'center',
523 gap: '0.75rem',
524 padding: '0.55rem 1rem',
525 background: '#1c1c1c',
526 borderBottom: '1px solid #333',
527 flexWrap: 'wrap'
528 }}
529 >
530 <strong>CommonRoom</strong>
531 <span
532 style={{
533 color: '#bbb',
534 overflow: 'hidden',
535 textOverflow: 'ellipsis',
536 whiteSpace: 'nowrap',
537 maxWidth: '40%'
538 }}
539 >
540 #{roomId}
541 </span>
542 <span style={{color: '#888', fontSize: '0.9rem'}}>
543 {count} of {MAX_PARTICIPANTS}
544 </span>
545 <span style={{flex: 1}} />
546 <CopyLinkButton compact />
547 <button
548 style={leaveBtn}
549 onClick={() => network.leave()}
550 title="Leave the room"
551 >
552 <Icon name="logout" size={15} />
553 Leave
554 </button>
555 </header>
557 <main style={{flex: 1, overflowY: 'auto', padding: '1rem'}}>
558 {notice && (
559 <div
560 style={{
561 background: '#3a2f10',
562 border: '1px solid #8a6d1a',
563 color: '#f0dfa2',
564 borderRadius: 8,
565 padding: '0.5rem 0.75rem',
566 margin: '0 auto 1rem',
567 maxWidth: 1100,
568 display: 'flex',
569 justifyContent: 'space-between',
570 alignItems: 'center',
571 gap: '0.5rem'
572 }}
573 >
574 <span>{notice}</span>
575 <button style={darkBtn} onClick={() => network.dismissNotice()}>
576 OK
577 </button>
578 </div>
579 )}
581 <div
582 style={{
583 display: 'grid',
584 gap: '0.75rem',
585 gridTemplateColumns: alone
586 ? 'minmax(0, 480px)'
587 : 'repeat(auto-fit, minmax(min(280px, 100%), 1fr))',
588 justifyContent: 'center',
589 maxWidth: 1100,
590 margin: '0 auto'
591 }}
592 >
593 <Tile
594 stream={screenStream ?? localStream}
595 label={`${name} (you)`}
596 isSelf
597 mirror={!sharing}
598 audioMuted={audioMuted}
599 videoMuted={videoMuted && !sharing}
600 connecting={false}
601 />
602 {participants.map(p => (
603 <ParticipantTile key={p.peerId} p={p} />
604 ))}
605 </div>
607 {alone && (
608 <div
609 style={{
610 maxWidth: 480,
611 margin: '1.25rem auto 0',
612 background: '#1c1c1c',
613 border: '1px solid #333',
614 borderRadius: 10,
615 padding: '1rem',
616 textAlign: 'center'
617 }}
618 >
619 <p style={{marginTop: 0}}>
620 You're the only one here. Share this link so others can join:
621 </p>
622 <p
623 style={{
624 color: '#9cc4ff',
625 wordBreak: 'break-all',
626 fontSize: '0.9rem',
627 userSelect: 'all'
628 }}
629 >
630 {location.href}
631 </p>
632 <CopyLinkButton compact={false} />
633 </div>
634 )}
635 </main>
637 <footer
638 style={{
639 display: 'flex',
640 alignItems: 'center',
641 justifyContent: 'center',
642 flexWrap: 'wrap',
643 gap: '0.6rem',
644 padding: '0.7rem 1rem',
645 background: '#1c1c1c',
646 borderTop: '1px solid #333'
647 }}
648 >
649 <button
650 style={audioMuted ? mutedIconBtn : iconBtn}
651 title={
652 audioMuted
653 ? micAvailable
654 ? 'Unmute your microphone'
655 : 'Microphone unavailableclick to try again'
656 : 'Mute your microphone'
657 }
658 aria-label={audioMuted ? 'Unmute your microphone' : 'Mute your microphone'}
659 onClick={() => network.setAudioMuted(!audioMuted)}
660 >
661 <Icon name={audioMuted ? 'micOff' : 'mic'} />
662 </button>
663 <button
664 style={videoMuted ? mutedIconBtn : iconBtn}
665 title={
666 videoMuted
667 ? camAvailable
668 ? 'Turn your camera on'
669 : 'Camera unavailableclick to try again'
670 : 'Turn your camera off'
671 }
672 aria-label={videoMuted ? 'Turn your camera on' : 'Turn your camera off'}
673 onClick={() => network.setVideoMuted(!videoMuted)}
674 >
675 <Icon name={videoMuted ? 'videoOff' : 'video'} />
676 </button>
677 {canShareScreen && (
678 <button
679 style={sharing ? sharingIconBtn : iconBtn}
680 title={sharing ? 'Stop sharing your screen' : 'Share your screen'}
681 aria-label={sharing ? 'Stop sharing your screen' : 'Share your screen'}
682 onClick={() =>
683 sharing
684 ? void network.stopScreenShare()
685 : void network.startScreenShare()
686 }
687 >
688 <Icon name="monitor" />
689 </button>
690 )}
691 <label
692 title="Video quality for the whole room — anyone can change it, and it applies to everyone"
693 style={{
694 display: 'flex',
695 alignItems: 'center',
696 gap: '0.4rem',
697 fontSize: '0.9rem',
698 color: '#ccc'
699 }}
700 >
701 Quality
702 <select
703 value={settings.videoQuality}
704 onChange={e => network.setVideoQuality(e.target.value as VideoQuality)}
705 style={{
706 padding: '0.4rem',
707 borderRadius: 8,
708 border: '1px solid #555',
709 background: '#2a2a2a',
710 color: '#eee',
711 fontSize: '0.9rem'
712 }}
713 >
714 {VIDEO_QUALITIES.map(q => (
715 <option key={q} value={q}>
716 {q[0].toUpperCase() + q.slice(1)}
717 </option>
718 ))}
719 </select>
720 </label>
721 </footer>
722 </div>
723 )
moveopenescclose