1import {useEffect, useRef, useState} from 'react'
2import {
3 MAX_PARTICIPANTS,
4 type ChatItem,
5 type ParticipantInfo
6} from './p2p/network'
7import {VIDEO_QUALITIES, type VideoQuality} from './p2p/settings'
8import {TURN_CONFIGURED, type RelayStatus} from './p2p/turn'
9import {useNetwork} from './useNetwork'
11// Screen capture is desktop-only in practice; hide the button where the API
12// doesn't exist (most mobile browsers).
13const canShareScreen =
14 typeof navigator.mediaDevices?.getDisplayMedia === 'function'
16const initialRoomFromHash = (): string => {
17 try {
18 return decodeURIComponent(location.hash.slice(1)).replace(/\s/g, '')
19 } catch {
20 return ''
21 }
22}
24// ---- styles ---------------------------------------------------------------
26const lightBtn: React.CSSProperties = {
27 padding: '0.45rem 1.1rem',
28 borderRadius: 6,
29 border: '1px solid #888',
30 background: '#fff',
31 cursor: 'pointer',
32 fontSize: '1rem'
33}
35const primaryBtn: React.CSSProperties = {
36 ...lightBtn,
37 background: '#1a7f37',
38 borderColor: '#1a7f37',
39 color: '#fff'
40}
42const disabledStyle: React.CSSProperties = {
43 opacity: 0.4,
44 cursor: 'not-allowed'
45}
47const inputStyle: React.CSSProperties = {
48 padding: '0.5rem',
49 fontSize: '1rem',
50 borderRadius: 6,
51 border: '1px solid #bbb',
52 width: '100%',
53 boxSizing: 'border-box'
54}
56// Dark in-room controls.
57const darkBtn: React.CSSProperties = {
58 padding: '0.5rem 0.9rem',
59 borderRadius: 8,
60 border: '1px solid #555',
61 background: '#2a2a2a',
62 color: '#eee',
63 cursor: 'pointer',
64 fontSize: '0.95rem',
65 display: 'inline-flex',
66 alignItems: 'center',
67 gap: '0.4rem'
68}
70// Square icon-only buttons for the control bar. Their meaning is carried by
71// the icon plus a title tooltip and aria-label.
72const iconBtn: React.CSSProperties = {
73 ...darkBtn,
74 padding: '0.65rem',
75 justifyContent: 'center'
76}
78// A mute button while muted — red, the universal "you are muted" signal.
79const mutedIconBtn: React.CSSProperties = {
80 ...iconBtn,
81 background: '#c62828',
82 borderColor: '#c62828',
83 color: '#fff'
84}
86// The screen-share button while sharing.
87const sharingIconBtn: React.CSSProperties = {
88 ...iconBtn,
89 background: '#1a7f37',
90 borderColor: '#1a7f37',
91 color: '#fff'
92}
94const leaveBtn: React.CSSProperties = {
95 ...darkBtn,
96 background: '#c62828',
97 borderColor: '#c62828',
98 color: '#fff'
99}
101// Chip overlaid on a tile (name label, mute badges).
102const tileChip: React.CSSProperties = {
103 background: 'rgba(0, 0, 0, 0.65)',
104 color: '#fff',
105 borderRadius: 6,
106 padding: '0.2rem 0.5rem',
107 fontSize: '0.85rem',
108 display: 'inline-flex',
109 alignItems: 'center',
110 gap: '0.3rem',
111 maxWidth: '90%'
112}
114// ---- icons (stroke-style paths from Feather icons, MIT) --------------------
116const ICONS = {
117 mic: (
118 <>
119 <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
120 <path d="M19 10v2a7 7 0 0 1-14 0v-2" />
121 <path d="M12 19v4" />
122 <path d="M8 23h8" />
123 </>
124 ),
125 micOff: (
126 <>
127 <path d="M1 1l22 22" />
128 <path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6" />
129 <path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23" />
130 <path d="M12 19v4" />
131 <path d="M8 23h8" />
132 </>
133 ),
134 video: (
135 <>
136 <path d="M23 7l-7 5 7 5V7z" />
137 <rect x="1" y="5" width="15" height="14" rx="2" />
138 </>
139 ),
140 videoOff: (
141 <>
142 <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" />
143 <path d="M1 1l22 22" />
144 </>
145 ),
146 monitor: (
147 <>
148 <rect x="2" y="3" width="20" height="14" rx="2" />
149 <path d="M8 21h8" />
150 <path d="M12 17v4" />
151 </>
152 ),
153 link: (
154 <>
155 <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
156 <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
157 </>
158 ),
159 logout: (
160 <>
161 <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
162 <path d="M16 17l5-5-5-5" />
163 <path d="M21 12H9" />
164 </>
165 ),
166 chat: (
167 <path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
168 ),
169 x: (
170 <>
171 <path d="M18 6L6 18" />
172 <path d="M6 6l12 12" />
173 </>
174 ),
175 send: (
176 <>
177 <path d="M22 2L11 13" />
178 <path d="M22 2l-7 20-4-9-9-4 20-7z" />
179 </>
180 )
181} as const
183function Icon({
184 name,
185 size = 18,
186 style
187}: {
188 name: keyof typeof ICONS
189 size?: number
190 style?: React.CSSProperties
191}) {
192 return (
193 <svg
194 width={size}
195 height={size}
196 viewBox="0 0 24 24"
197 fill="none"
198 stroke="currentColor"
199 strokeWidth={2}
200 strokeLinecap="round"
201 strokeLinejoin="round"
202 style={{display: 'block', ...style}}
203 aria-hidden
204 >
205 {ICONS[name]}
206 </svg>
207 )
208}
210// ---- video tile -------------------------------------------------------------
212function VideoView({
213 stream,
214 muted,
215 mirror,
216 fit
217}: {
218 stream: MediaStream | null
219 muted: boolean
220 mirror: boolean
221 fit: 'cover' | 'contain'
222}) {
223 const ref = useRef<HTMLVideoElement>(null)
224 useEffect(() => {
225 if (ref.current && ref.current.srcObject !== stream) {
226 ref.current.srcObject = stream
227 }
228 }, [stream])
229 return (
230 <video
231 ref={ref}
232 autoPlay
233 playsInline
234 muted={muted}
235 style={{
236 position: 'absolute',
237 inset: 0,
238 width: '100%',
239 height: '100%',
240 objectFit: fit,
241 transform: mirror ? 'scaleX(-1)' : undefined
242 }}
243 />
244 )
245}
247function Tile({
248 stream,
249 label,
250 isSelf,
251 mirror,
252 audioMuted,
253 videoMuted,
254 connecting,
255 fill = false,
256 onClick,
257 tooltip
258}: {
259 stream: MediaStream | null
260 label: string
261 isSelf: boolean
262 mirror: boolean
263 audioMuted: boolean
264 videoMuted: boolean
265 connecting: boolean
266 /** Fill the parent box (spotlight) instead of a fixed 4:3 grid cell. */
267 fill?: boolean
268 onClick?: () => void
269 tooltip?: string
270}) {
271 return (
272 <div
273 onClick={onClick}
274 title={tooltip}
275 style={{
276 position: 'relative',
277 background: '#000',
278 borderRadius: 10,
279 overflow: 'hidden',
280 // The spotlight tile letterboxes (contain) so screen shares and faces
281 // are never cropped; grid/filmstrip cells crop to fill (cover).
282 ...(fill ? {width: '100%', height: '100%'} : {aspectRatio: '4 / 3'}),
283 border: isSelf ? '1px solid #444' : '1px solid #222',
284 cursor: onClick ? 'pointer' : undefined
285 }}
286 >
287 {/* The video element stays mounted even when their camera is off so the
288 audio keeps playing; the placeholder just covers it. */}
289 <VideoView
290 stream={stream}
291 muted={isSelf}
292 mirror={mirror}
293 fit={fill ? 'contain' : 'cover'}
294 />
295 {(videoMuted || connecting) && (
296 <div
297 style={{
298 position: 'absolute',
299 inset: 0,
300 background: '#222',
301 display: 'flex',
302 flexDirection: 'column',
303 alignItems: 'center',
304 justifyContent: 'center',
305 gap: '0.5rem'
306 }}
307 >
308 <div
309 style={{
310 width: 64,
311 height: 64,
312 borderRadius: '50%',
313 background: '#3a3a3a',
314 color: '#ddd',
315 display: 'flex',
316 alignItems: 'center',
317 justifyContent: 'center',
318 fontSize: '1.6rem',
319 fontFamily: 'sans-serif'
320 }}
321 >
322 {(label[0] ?? '?').toUpperCase()}
323 </div>
324 {connecting && (
325 <span style={{color: '#999', fontSize: '0.9rem'}}>Connecting…</span>
326 )}
327 </div>
328 )}
329 <div
330 style={{
331 position: 'absolute',
332 left: 8,
333 bottom: 8,
334 display: 'flex',
335 gap: '0.35rem',
336 alignItems: 'center'
337 }}
338 >
339 <span style={tileChip}>
340 <span
341 style={{
342 overflow: 'hidden',
343 textOverflow: 'ellipsis',
344 whiteSpace: 'nowrap'
345 }}
346 >
347 {label}
348 </span>
349 {audioMuted && <Icon name="micOff" size={13} />}
350 </span>
351 </div>
352 </div>
353 )
354}
356// ---- landing ------------------------------------------------------------------
358function Landing({
359 notice,
360 initialName,
361 initialTurnToken,
362 onDismissNotice,
363 onEnter
364}: {
365 notice: string | null
366 initialName: string
367 initialTurnToken: string
368 onDismissNotice: () => void
369 onEnter: (name: string, room: string, turnToken: string) => void
370}) {
371 const [name, setName] = useState(initialName)
372 const [room, setRoom] = useState(initialRoomFromHash)
373 const [turnToken, setTurnToken] = useState(initialTurnToken)
374 const canEnter = name.trim().length > 0 && room.length > 0
375 const submit = (e: React.FormEvent) => {
376 e.preventDefault()
377 if (canEnter) onEnter(name, room, turnToken)
378 }
379 return (
380 <div
381 style={{
382 fontFamily: 'sans-serif',
383 maxWidth: 460,
384 margin: '3rem auto',
385 padding: '0 1rem'
386 }}
387 >
388 <h1 style={{marginBottom: '0.25rem'}}>CommonRoom</h1>
389 <p style={{color: '#666', marginTop: 0}}>
390 Group video calls with no server. Pick a room, share the link, and
391 talk — everything flows peer-to-peer.
392 </p>
394 {notice && (
395 <div
396 style={{
397 background: '#fff3cd',
398 border: '1px solid #e0c968',
399 borderRadius: 6,
400 padding: '0.5rem 0.75rem',
401 margin: '0.75rem 0',
402 display: 'flex',
403 justifyContent: 'space-between',
404 alignItems: 'center',
405 gap: '0.5rem'
406 }}
407 >
408 <span>{notice}</span>
409 <button style={lightBtn} onClick={onDismissNotice}>
410 OK
411 </button>
412 </div>
413 )}
415 <form onSubmit={submit} style={{marginTop: '1.5rem'}}>
416 <label style={{display: 'block', marginBottom: '1rem'}}>
417 <div style={{marginBottom: '0.3rem'}}>Your name</div>
418 <input
419 autoFocus={!initialName}
420 value={name}
421 onChange={e => setName(e.target.value)}
422 placeholder="e.g. Jeremy"
423 maxLength={40}
424 style={inputStyle}
425 />
426 </label>
427 <label style={{display: 'block', marginBottom: '1.25rem'}}>
428 <div style={{marginBottom: '0.3rem'}}>Room name</div>
429 <input
430 autoFocus={!!initialName}
431 value={room}
432 onChange={e => setRoom(e.target.value.replace(/\s/g, ''))}
433 placeholder="any-name-you-like (no spaces)"
434 maxLength={100}
435 style={inputStyle}
436 />
437 </label>
438 {TURN_CONFIGURED && (
439 <details
440 open={initialTurnToken.length > 0}
441 style={{marginBottom: '1.25rem'}}
442 >
443 <summary style={{cursor: 'pointer', color: '#555'}}>
444 Relay token (optional)
445 </summary>
446 <input
447 type="password"
448 value={turnToken}
449 onChange={e => setTurnToken(e.target.value)}
450 placeholder="leave blank if you don't have one"
451 maxLength={200}
452 style={{...inputStyle, marginTop: '0.5rem'}}
453 />
454 <p style={{color: '#888', fontSize: '0.85rem', marginBottom: 0}}>
455 Some networks won't let two browsers talk directly, and those
456 need a relay. Only one person in the room needs to enter the
457 token — everyone else picks the relay up from them
458 automatically. The token stays in this browser.
459 </p>
460 </details>
461 )}
462 <button
463 type="submit"
464 style={canEnter ? primaryBtn : {...primaryBtn, ...disabledStyle}}
465 disabled={!canEnter}
466 >
467 Enter room
468 </button>
469 </form>
471 <p style={{color: '#888', fontSize: '0.85rem', marginTop: '1.5rem'}}>
472 Anyone who knows the room name can join — up to {MAX_PARTICIPANTS}{' '}
473 people per room. You enter with your microphone and camera off.
474 </p>
475 </div>
476 )
477}
479// ---- room ----------------------------------------------------------------------
481function CopyLinkButton({compact}: {compact: boolean}) {
482 const [copied, setCopied] = useState(false)
483 const copy = async () => {
484 try {
485 await navigator.clipboard.writeText(location.href)
486 setCopied(true)
487 setTimeout(() => setCopied(false), 1500)
488 } catch {
489 /* clipboard unavailable; the address bar still has the link */
490 }
491 }
492 return (
493 <button
494 style={darkBtn}
495 onClick={() => void copy()}
496 title="Copy the room link to share"
497 >
498 <Icon name="link" size={15} />
499 {copied ? 'Copied!' : compact ? 'Copy link' : 'Copy room link'}
500 </button>
501 )
502}
504// A quiet indicator of whether the room has a relay available, and where it
505// came from. Only shown on builds that have a credential endpoint at all.
506function RelayChip({status}: {status: RelayStatus}) {
507 const [color, label, title] =
508 status === 'self'
509 ? [
510 '#3fb950',
511 'Relay on',
512 'A relay is available from the token you entered, and has been shared with everyone else in the room.'
513 ]
514 : status === 'shared'
515 ? [
516 '#3fb950',
517 'Relay shared',
518 'Another participant shared a relay with the room, so difficult connections can fall back to it.'
519 ]
520 : [
521 '#777',
522 'No relay',
523 "Nobody in the room has entered a relay token. Most connections are fine without one; if someone can't connect, rejoin with a token."
524 ]
525 return (
526 <span
527 title={title}
528 style={{
529 display: 'inline-flex',
530 alignItems: 'center',
531 gap: '0.4rem',
532 fontSize: '0.85rem',
533 color: '#999',
534 cursor: 'default'
535 }}
536 >
537 <span
538 style={{
539 width: 8,
540 height: 8,
541 borderRadius: '50%',
542 background: color,
543 flex: '0 0 auto'
544 }}
545 />
546 {label}
547 </span>
548 )
549}
551// ---- chat -----------------------------------------------------------------
553const useMediaQuery = (query: string): boolean => {
554 const [matches, setMatches] = useState(() => matchMedia(query).matches)
555 useEffect(() => {
556 const mq = matchMedia(query)
557 const onChange = () => setMatches(mq.matches)
558 mq.addEventListener('change', onChange)
559 return () => mq.removeEventListener('change', onChange)
560 }, [query])
561 return matches
562}
564// Make http(s) URLs clickable. Only URLs we matched ourselves become hrefs
565// (never arbitrary schemes), and common trailing punctuation stays as text.
566const URL_RE = /https?:\/\/[^\s]+/g
568function withLinks(text: string): React.ReactNode[] {
569 const out: React.ReactNode[] = []
570 let last = 0
571 let m: RegExpExecArray | null
572 URL_RE.lastIndex = 0
573 while ((m = URL_RE.exec(text))) {
574 let url = m[0]
575 const trail = url.match(/[.,!?;:)\]'"]+$/)
576 if (trail) url = url.slice(0, -trail[0].length)
577 if (url.replace(/^https?:\/\//, '') === '') continue // scheme only
578 if (m.index > last) out.push(text.slice(last, m.index))
579 out.push(
580 <a
581 key={m.index}
582 href={url}
583 target="_blank"
584 rel="noopener noreferrer"
585 style={{color: '#9cc4ff', wordBreak: 'break-all'}}
586 >
587 {url}
588 </a>
589 )
590 last = m.index + url.length
591 URL_RE.lastIndex = last
592 }
593 if (last < text.length) out.push(text.slice(last))
594 return out
595}
597const timeLabel = (t: number): string =>
598 new Date(t).toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'})
600function ChatPanel({
601 items,
602 overlay,
603 onSend,
604 onClose
605}: {
606 items: ChatItem[]
607 overlay: boolean
608 onSend: (text: string) => void
609 onClose: () => void
610}) {
611 const [draft, setDraft] = useState('')
612 const listRef = useRef<HTMLDivElement>(null)
613 // Stick to the bottom unless the user has scrolled up to read.
614 const stickRef = useRef(true)
615 useEffect(() => {
616 const el = listRef.current
617 if (el && stickRef.current) el.scrollTop = el.scrollHeight
618 }, [items.length])
620 const submit = (e: React.FormEvent) => {
621 e.preventDefault()
622 if (!draft.trim()) return
623 onSend(draft)
624 setDraft('')
625 stickRef.current = true
626 }
628 return (
629 <aside
630 style={{
631 ...(overlay
632 ? {
633 position: 'absolute',
634 top: 0,
635 right: 0,
636 bottom: 0,
637 width: 'min(320px, 88vw)',
638 zIndex: 10,
639 boxShadow: '-4px 0 16px rgba(0, 0, 0, 0.5)'
640 }
641 : {width: 300, flex: '0 0 auto'}),
642 background: '#161616',
643 borderLeft: '1px solid #333',
644 display: 'flex',
645 flexDirection: 'column',
646 minHeight: 0
647 }}
648 >
649 <div
650 style={{
651 display: 'flex',
652 justifyContent: 'space-between',
653 alignItems: 'center',
654 padding: '0.45rem 0.45rem 0.45rem 0.75rem',
655 borderBottom: '1px solid #333'
656 }}
657 >
658 <strong>Chat</strong>
659 <button
660 style={{...iconBtn, padding: '0.35rem', border: 'none', background: 'none'}}
661 onClick={onClose}
662 title="Close chat"
663 aria-label="Close chat"
664 >
665 <Icon name="x" size={16} />
666 </button>
667 </div>
668 <div
669 ref={listRef}
670 onScroll={() => {
671 const el = listRef.current
672 if (el) {
673 stickRef.current =
674 el.scrollHeight - el.scrollTop - el.clientHeight < 60
675 }
676 }}
677 style={{flex: 1, minHeight: 0, overflowY: 'auto', padding: '0.6rem 0.75rem'}}
678 >
679 {items.length === 0 && (
680 <p style={{color: '#777', fontSize: '0.85rem'}}>
681 No messages yet. Only people in the room see the chat, and nothing
682 is stored anywhere.
683 </p>
684 )}
685 {items.map(item =>
686 item.kind === 'system' ? (
687 <div
688 key={item.seq}
689 style={{
690 textAlign: 'center',
691 color: '#888',
692 fontStyle: 'italic',
693 fontSize: '0.8rem',
694 margin: '0.45rem 0'
695 }}
696 >
697 {item.text} · {timeLabel(item.time)}
698 </div>
699 ) : (
700 <div key={item.seq} style={{marginBottom: '0.55rem'}}>
701 <div style={{fontSize: '0.75rem', color: '#888'}}>
702 <strong style={{color: '#ccc'}}>{item.name}</strong>{' '}
703 {timeLabel(item.time)}
704 </div>
705 <div
706 style={{
707 whiteSpace: 'pre-wrap',
708 wordBreak: 'break-word',
709 fontSize: '0.92rem'
710 }}
711 >
712 {withLinks(item.text)}
713 </div>
714 </div>
715 )
716 )}
717 </div>
718 <form
719 onSubmit={submit}
720 style={{
721 display: 'flex',
722 gap: '0.4rem',
723 padding: '0.6rem',
724 borderTop: '1px solid #333'
725 }}
726 >
727 <input
728 autoFocus
729 value={draft}
730 onChange={e => setDraft(e.target.value)}
731 placeholder="Message…"
732 maxLength={2000}
733 style={{
734 flex: 1,
735 minWidth: 0,
736 padding: '0.45rem 0.6rem',
737 borderRadius: 8,
738 border: '1px solid #555',
739 background: '#2a2a2a',
740 color: '#eee',
741 fontSize: '0.92rem'
742 }}
743 />
744 <button
745 type="submit"
746 style={draft.trim() ? iconBtn : {...iconBtn, ...disabledStyle}}
747 disabled={!draft.trim()}
748 title="Send"
749 aria-label="Send message"
750 >
751 <Icon name="send" size={16} />
752 </button>
753 </form>
754 </aside>
755 )
756}
758export default function App() {
759 const {snapshot, network} = useNetwork()
760 const {
761 phase,
762 roomId,
763 name,
764 participants,
765 audioMuted,
766 videoMuted,
767 micAvailable,
768 camAvailable,
769 localStream,
770 screenStream,
771 settings,
772 chat,
773 relay,
774 notice
775 } = snapshot
777 // Spotlight: which tile is enlarged ('self', a peer ID, or null = gallery).
778 const [focusedId, setFocusedId] = useState<string | null>(null)
779 const focusValid =
780 focusedId !== null &&
781 (focusedId === 'self' || participants.some(p => p.peerId === focusedId))
782 const focus = focusValid ? focusedId : null
783 useEffect(() => {
784 // Drop the spotlight when its subject leaves (or we leave the room).
785 if (focusedId !== null && (phase !== 'room' || !focusValid)) {
786 setFocusedId(null)
787 }
788 }, [phase, focusedId, focusValid])
789 useEffect(() => {
790 if (!focus) return
791 const onKey = (e: KeyboardEvent) => {
792 if (e.key === 'Escape') setFocusedId(null)
793 }
794 window.addEventListener('keydown', onKey)
795 return () => window.removeEventListener('keydown', onKey)
796 }, [focus])
798 // Chat panel: side panel on wide screens, overlay on narrow ones. The
799 // unread badge counts real messages (not join/left lines) that arrived
800 // while the panel was closed.
801 const [chatOpen, setChatOpen] = useState(false)
802 const [readCount, setReadCount] = useState(0)
803 const narrow = useMediaQuery('(max-width: 700px)')
804 useEffect(() => {
805 if (phase !== 'room' && chatOpen) setChatOpen(false)
806 }, [phase, chatOpen])
807 useEffect(() => {
808 // Follows the log while open; also snaps back when the log resets on leave.
809 if ((chatOpen || readCount > chat.length) && readCount !== chat.length) {
810 setReadCount(chat.length)
811 }
812 }, [chatOpen, readCount, chat.length])
813 const unread = chatOpen
814 ? 0
815 : chat.slice(readCount).filter(m => m.kind === 'chat').length
817 if (phase === 'landing') {
818 return (
819 <Landing
820 notice={notice}
821 initialName={network.savedName}
822 initialTurnToken={network.savedTurnToken}
823 onDismissNotice={() => network.dismissNotice()}
824 onEnter={(n, r, t) => void network.enterRoom(n, r, t)}
825 />
826 )
827 }
829 if (phase === 'joining') {
830 return (
831 <div
832 style={{
833 fontFamily: 'sans-serif',
834 maxWidth: 460,
835 margin: '3rem auto',
836 padding: '0 1rem'
837 }}
838 >
839 <h1 style={{marginBottom: '0.25rem'}}>CommonRoom</h1>
840 <p>
841 Joining <strong>#{roomId}</strong>… your browser may ask for camera
842 and microphone access (you'll still be muted until you turn them on).
843 </p>
844 <button style={lightBtn} onClick={() => network.leave()}>
845 Cancel
846 </button>
847 </div>
848 )
849 }
851 // ---- in-room (dark) ----
852 const sharing = screenStream !== null
853 const count = participants.length + 1
854 const alone = participants.length === 0
856 const tileTooltip = (focused: boolean) =>
857 focused ? 'Click to return to the gallery (Esc)' : 'Click to enlarge'
858 const selfTile = (focused: boolean) => (
859 <Tile
860 stream={screenStream ?? localStream}
861 label={`${name} (you)`}
862 isSelf
863 mirror={!sharing}
864 audioMuted={audioMuted}
865 videoMuted={videoMuted && !sharing}
866 connecting={false}
867 fill={focused}
868 tooltip={tileTooltip(focused)}
869 onClick={() => setFocusedId(focused ? null : 'self')}
870 />
871 )
872 const peerTile = (p: ParticipantInfo, focused: boolean) => (
873 <Tile
874 stream={p.stream}
875 label={p.name}
876 isSelf={false}
877 mirror={false}
878 audioMuted={p.audioMuted}
879 videoMuted={p.videoMuted}
880 connecting={!p.connected}
881 fill={focused}
882 tooltip={tileTooltip(focused)}
883 onClick={() => setFocusedId(focused ? null : p.peerId)}
884 />
885 )
886 const focusedPeer =
887 focus && focus !== 'self'
888 ? participants.find(p => p.peerId === focus)
889 : undefined
890 const stripPeers = focus
891 ? participants.filter(p => p.peerId !== focus)
892 : []
894 return (
895 <div
896 style={{
897 position: 'fixed',
898 inset: 0,
899 background: '#111',
900 color: '#eee',
901 fontFamily: 'sans-serif',
902 display: 'flex',
903 flexDirection: 'column'
904 }}
905 >
906 <header
907 style={{
908 display: 'flex',
909 alignItems: 'center',
910 gap: '0.75rem',
911 padding: '0.55rem 1rem',
912 background: '#1c1c1c',
913 borderBottom: '1px solid #333',
914 flexWrap: 'wrap'
915 }}
916 >
917 <strong>CommonRoom</strong>
918 <span
919 style={{
920 color: '#bbb',
921 overflow: 'hidden',
922 textOverflow: 'ellipsis',
923 whiteSpace: 'nowrap',
924 maxWidth: '40%'
925 }}
926 >
927 #{roomId}
928 </span>
929 <span style={{color: '#888', fontSize: '0.9rem'}}>
930 {count} of {MAX_PARTICIPANTS}
931 </span>
932 <span style={{flex: 1}} />
933 <CopyLinkButton compact />
934 <button
935 style={leaveBtn}
936 onClick={() => network.leave()}
937 title="Leave the room"
938 >
939 <Icon name="logout" size={15} />
940 Leave
941 </button>
942 </header>
944 <div
945 style={{
946 flex: 1,
947 minHeight: 0,
948 display: 'flex',
949 position: 'relative'
950 }}
951 >
952 <main
953 style={{
954 flex: 1,
955 minWidth: 0,
956 overflowY: focus ? 'hidden' : 'auto',
957 padding: '1rem',
958 display: 'flex',
959 flexDirection: 'column'
960 }}
961 >
962 {notice && (
963 <div
964 style={{
965 background: '#3a2f10',
966 border: '1px solid #8a6d1a',
967 color: '#f0dfa2',
968 borderRadius: 8,
969 padding: '0.5rem 0.75rem',
970 margin: '0 auto 1rem',
971 maxWidth: 1100,
972 display: 'flex',
973 justifyContent: 'space-between',
974 alignItems: 'center',
975 gap: '0.5rem'
976 }}
977 >
978 <span>{notice}</span>
979 <button style={darkBtn} onClick={() => network.dismissNotice()}>
980 OK
981 </button>
982 </div>
983 )}
985 {focus ? (
986 <>
987 {/* Spotlight: the chosen tile takes the available space… */}
988 <div style={{flex: 1, minHeight: 0}}>
989 {focus === 'self'
990 ? selfTile(true)
991 : focusedPeer && peerTile(focusedPeer, true)}
992 </div>
993 {/* …and everyone else shrinks to a filmstrip below it. */}
994 {(focus !== 'self' || stripPeers.length > 0) && (
995 <div
996 style={{
997 overflowX: 'auto',
998 flex: '0 0 auto',
999 marginTop: '0.75rem'
1000 }}
1001 >
1002 <div
1003 style={{
1004 display: 'flex',
1005 gap: '0.5rem',
1006 width: 'max-content',
1007 margin: '0 auto'
1008 }}
1009 >
1010 {focus !== 'self' && (
1011 <div style={{width: 150, flex: '0 0 auto'}}>
1012 {selfTile(false)}
1013 </div>
1014 )}
1015 {stripPeers.map(p => (
1016 <div key={p.peerId} style={{width: 150, flex: '0 0 auto'}}>
1017 {peerTile(p, false)}
1018 </div>
1019 ))}
1020 </div>
1021 </div>
1022 )}
1023 </>
1024 ) : (
1025 <>
1026 <div
1027 style={{
1028 display: 'grid',
1029 gap: '0.75rem',
1030 gridTemplateColumns: alone
1031 ? 'minmax(0, 480px)'
1032 : 'repeat(auto-fit, minmax(min(280px, 100%), 1fr))',
1033 justifyContent: 'center',
1034 maxWidth: 1100,
1035 width: '100%',
1036 margin: '0 auto'
1037 }}
1038 >
1039 {selfTile(false)}
1040 {participants.map(p => (
1041 <div key={p.peerId} style={{minWidth: 0}}>
1042 {peerTile(p, false)}
1043 </div>
1044 ))}
1045 </div>
1047 {alone && (
1048 <div
1049 style={{
1050 maxWidth: 480,
1051 margin: '1.25rem auto 0',
1052 background: '#1c1c1c',
1053 border: '1px solid #333',
1054 borderRadius: 10,
1055 padding: '1rem',
1056 textAlign: 'center'
1057 }}
1058 >
1059 <p style={{marginTop: 0}}>
1060 You're the only one here. Share this link so others can join:
1061 </p>
1062 <p
1063 style={{
1064 color: '#9cc4ff',
1065 wordBreak: 'break-all',
1066 fontSize: '0.9rem',
1067 userSelect: 'all'
1068 }}
1069 >
1070 {location.href}
1071 </p>
1072 <CopyLinkButton compact={false} />
1073 </div>
1074 )}
1075 </>
1076 )}
1077 </main>
1078 {chatOpen && (
1079 <ChatPanel
1080 items={chat}
1081 overlay={narrow}
1082 onSend={text => network.sendChat(text)}
1083 onClose={() => setChatOpen(false)}
1084 />
1085 )}
1086 </div>
1088 <footer
1089 style={{
1090 display: 'flex',
1091 alignItems: 'center',
1092 justifyContent: 'center',
1093 flexWrap: 'wrap',
1094 gap: '0.6rem',
1095 padding: '0.7rem 1rem',
1096 background: '#1c1c1c',
1097 borderTop: '1px solid #333'
1098 }}
1099 >
1100 <button
1101 style={audioMuted ? mutedIconBtn : iconBtn}
1102 title={
1103 audioMuted
1104 ? micAvailable
1105 ? 'Unmute your microphone'
1106 : 'Microphone unavailable — click to try again'
1107 : 'Mute your microphone'
1108 }
1109 aria-label={audioMuted ? 'Unmute your microphone' : 'Mute your microphone'}
1110 onClick={() => network.setAudioMuted(!audioMuted)}
1111 >
1112 <Icon name={audioMuted ? 'micOff' : 'mic'} />
1113 </button>
1114 <button
1115 style={videoMuted ? mutedIconBtn : iconBtn}
1116 title={
1117 videoMuted
1118 ? camAvailable
1119 ? 'Turn your camera on'
1120 : 'Camera unavailable — click to try again'
1121 : 'Turn your camera off'
1122 }
1123 aria-label={videoMuted ? 'Turn your camera on' : 'Turn your camera off'}
1124 onClick={() => network.setVideoMuted(!videoMuted)}
1125 >
1126 <Icon name={videoMuted ? 'videoOff' : 'video'} />
1127 </button>
1128 {canShareScreen && (
1129 <button
1130 style={sharing ? sharingIconBtn : iconBtn}
1131 title={sharing ? 'Stop sharing your screen' : 'Share your screen'}
1132 aria-label={sharing ? 'Stop sharing your screen' : 'Share your screen'}
1133 onClick={() =>
1134 sharing
1135 ? void network.stopScreenShare()
1136 : void network.startScreenShare()
1137 }
1138 >
1139 <Icon name="monitor" />
1140 </button>
1141 )}
1142 <span style={{position: 'relative', display: 'inline-flex'}}>
1143 <button
1144 style={chatOpen ? {...iconBtn, background: '#3a3a3a'} : iconBtn}
1145 title={chatOpen ? 'Close the chat' : 'Open the chat'}
1146 aria-label={chatOpen ? 'Close the chat' : 'Open the chat'}
1147 onClick={() => setChatOpen(!chatOpen)}
1148 >
1149 <Icon name="chat" />
1150 </button>
1151 {unread > 0 && (
1152 <span
1153 style={{
1154 position: 'absolute',
1155 top: -5,
1156 right: -5,
1157 minWidth: 18,
1158 height: 18,
1159 borderRadius: 9,
1160 background: '#c62828',
1161 color: '#fff',
1162 fontSize: '0.7rem',
1163 display: 'flex',
1164 alignItems: 'center',
1165 justifyContent: 'center',
1166 padding: '0 4px',
1167 pointerEvents: 'none'
1168 }}
1169 >
1170 {unread > 99 ? '99+' : unread}
1171 </span>
1172 )}
1173 </span>
1174 <label
1175 title="Video quality for the whole room — anyone can change it, and it applies to everyone"
1176 style={{
1177 display: 'flex',
1178 alignItems: 'center',
1179 gap: '0.4rem',
1180 fontSize: '0.9rem',
1181 color: '#ccc'
1182 }}
1183 >
1184 Quality
1185 <select
1186 value={settings.videoQuality}
1187 onChange={e => network.setVideoQuality(e.target.value as VideoQuality)}
1188 style={{
1189 padding: '0.4rem',
1190 borderRadius: 8,
1191 border: '1px solid #555',
1192 background: '#2a2a2a',
1193 color: '#eee',
1194 fontSize: '0.9rem'
1195 }}
1196 >
1197 {VIDEO_QUALITIES.map(q => (
1198 <option key={q} value={q}>
1199 {q[0].toUpperCase() + q.slice(1)}
1200 </option>
1201 ))}
1202 </select>
1203 </label>
1204 {TURN_CONFIGURED && <RelayChip status={relay} />}
1205 </footer>
1206 </div>
1207 )
1208}