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