Files
ScreenShare/apps/ScreenShareWeb/src/App.tsx
T

388 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState } from 'react'
import { Room, RoomEvent, Track } from 'livekit-client'
import './App.css'
type Role = 'host' | 'publisher' | 'viewer'
type User = {
id: string
username: string
displayName: string
isAdmin: boolean
}
type Session = { token: string; user: User }
type TokenResponse = {
roomName: string
participantIdentity: string
participantName: string
livekitUrl: string
token: string
role: Role
}
type ScreenFeed = { track: Track; label: string }
type RoomHistoryItem = { id: string; title: string; visitedAt: string }
const ROOM_HISTORY_KEY = 'screen-share-room-history'
function ScreenTrack({ track, label }: ScreenFeed) {
const mediaContainerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const mediaElement = track.attach()
mediaElement.autoplay = true
if (mediaElement instanceof HTMLVideoElement) {
mediaElement.muted = true
mediaElement.playsInline = true
void mediaElement.play().catch(() => undefined)
}
mediaContainerRef.current?.append(mediaElement)
return () => mediaElement.remove()
}, [track])
return (
<figure className="screen-frame">
<figcaption>{label}</figcaption>
<div className="media-slot" ref={mediaContainerRef} />
</figure>
)
}
async function apiRequest<T>(path: string, options: RequestInit = {}, token?: string): Promise<T> {
const response = await fetch(path, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: 'Bearer ' + token } : {}),
...options.headers,
},
})
const payload = (await response.json()) as T | { message: string }
if (!response.ok) {
const message = typeof payload === 'object' && payload !== null && 'message' in payload
? String(payload.message)
: '请求失败'
throw new Error(message)
}
return payload as T
}
function App() {
const roomRef = useRef<Room | null>(null)
const [session, setSession] = useState<Session | null>(null)
const [authMode, setAuthMode] = useState<'login' | 'register'>('login')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [displayName, setDisplayName] = useState('')
const [inviteCode, setInviteCode] = useState('')
const [roomId, setRoomId] = useState('')
const [roomTitle, setRoomTitle] = useState('')
const [roomHistory, setRoomHistory] = useState<RoomHistoryItem[]>(() => {
try {
const stored = localStorage.getItem(ROOM_HISTORY_KEY)
return stored ? JSON.parse(stored) as RoomHistoryItem[] : []
} catch {
return []
}
})
const [connectionState, setConnectionState] = useState<'idle' | 'connecting' | 'connected'>('idle')
const [roomRole, setRoomRole] = useState<Role | null>(null)
const [isSharing, setIsSharing] = useState(false)
const [remoteCount, setRemoteCount] = useState(0)
const [localScreen, setLocalScreen] = useState<ScreenFeed | null>(null)
const [remoteScreens, setRemoteScreens] = useState<ScreenFeed[]>([])
const [createdInvite, setCreatedInvite] = useState('')
const [error, setError] = useState('')
useEffect(() => {
const stored = localStorage.getItem('screen-share-session')
if (!stored) return
const saved = JSON.parse(stored) as Session
void apiRequest<User>('/api/auth/me', {}, saved.token)
.then((user) => setSession({ token: saved.token, user }))
.catch(() => localStorage.removeItem('screen-share-session'))
}, [])
useEffect(() => () => {
void roomRef.current?.disconnect()
}, [])
function saveSession(next: Session) {
localStorage.setItem('screen-share-session', JSON.stringify(next))
setSession(next)
}
function refreshRemoteCount(room: Room) {
setRemoteCount(room.remoteParticipants.size)
}
function rememberRoom(room: Pick<RoomHistoryItem, 'id' | 'title'>) {
setRoomHistory((history) => {
const next = [
{ ...room, visitedAt: new Date().toISOString() },
...history.filter((item) => item.id !== room.id),
].slice(0, 12)
localStorage.setItem(ROOM_HISTORY_KEY, JSON.stringify(next))
return next
})
}
async function submitAuth() {
setError('')
try {
const body = authMode === 'login'
? { username, password }
: { username, password, displayName, inviteCode }
const result = await apiRequest<Session>('/api/auth/' + authMode, {
method: 'POST',
body: JSON.stringify(body),
})
saveSession(result)
setPassword('')
setInviteCode('')
} catch (cause) {
setError(cause instanceof Error ? cause.message : '认证失败')
}
}
async function createInvite(type: 'user' | 'admin') {
if (!session) return
setError('')
try {
const result = await apiRequest<{ code: string }>('/api/admin/invitations', {
method: 'POST',
body: JSON.stringify({ type, expiresInHours: 168 }),
}, session.token)
setCreatedInvite(result.code)
} catch (cause) {
setError(cause instanceof Error ? cause.message : '创建邀请码失败')
}
}
async function createRoom() {
if (!session || !roomTitle.trim()) {
setError('请输入房间标题。')
return
}
setError('')
try {
const room = await apiRequest<{ id: string; title: string }>('/api/rooms', {
method: 'POST',
body: JSON.stringify({ title: roomTitle.trim() }),
}, session.token)
setRoomId(room.id)
setRoomTitle(room.title)
rememberRoom(room)
await connectRoom(room.id)
} catch (cause) {
setError(cause instanceof Error ? cause.message : '创建房间失败')
}
}
async function joinRoomByTitle() {
if (!session || !roomTitle.trim()) {
setError('请输入房间标题。')
return
}
setError('')
try {
const room = await apiRequest<{ id: string; title: string }>('/api/rooms/join-by-title', {
method: 'POST',
body: JSON.stringify({ title: roomTitle.trim() }),
}, session.token)
setRoomId(room.id)
setRoomTitle(room.title)
rememberRoom(room)
await connectRoom(room.id)
} catch (cause) {
setError(cause instanceof Error ? cause.message : '加入房间失败')
}
}
async function connectRoom(targetRoomId = roomId.trim()) {
if (!session) return
if (!targetRoomId) {
setError('请输入房间 ID。')
return
}
setError('')
setConnectionState('connecting')
try {
await apiRequest('/api/rooms/' + encodeURIComponent(targetRoomId) + '/join', {
method: 'POST',
body: '{}',
}, session.token)
const payload = await apiRequest<TokenResponse>('/api/rooms/' + encodeURIComponent(targetRoomId) + '/token', {
method: 'POST',
body: '{}',
}, session.token)
roomRef.current?.disconnect()
setLocalScreen(null)
setRemoteScreens([])
const room = new Room()
room.on(RoomEvent.ParticipantConnected, () => refreshRemoteCount(room))
room.on(RoomEvent.ParticipantDisconnected, () => refreshRemoteCount(room))
room.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
if (track.kind === Track.Kind.Video && publication.source === Track.Source.ScreenShare) {
setRemoteScreens((screens) => [
...screens.filter((screen) => screen.track.sid !== track.sid),
{ track, label: (participant.name || participant.identity) + ' 的屏幕' },
])
}
})
room.on(RoomEvent.TrackUnsubscribed, (track) => {
setRemoteScreens((screens) => screens.filter((screen) => screen.track !== track))
})
room.on(RoomEvent.LocalTrackPublished, (publication) => {
if (publication.source === Track.Source.ScreenShare && publication.track) {
setLocalScreen({ track: publication.track, label: '你正在共享的屏幕' })
setIsSharing(true)
}
})
room.on(RoomEvent.LocalTrackUnpublished, (publication) => {
if (publication.source === Track.Source.ScreenShare) {
setLocalScreen(null)
setIsSharing(false)
}
})
room.on(RoomEvent.Disconnected, () => {
setConnectionState('idle')
setRoomRole(null)
setIsSharing(false)
setRemoteCount(0)
setLocalScreen(null)
setRemoteScreens([])
})
await room.connect(payload.livekitUrl, payload.token)
roomRef.current = room
setRoomId(targetRoomId)
setRoomRole(payload.role)
refreshRemoteCount(room)
setConnectionState('connected')
} catch (cause) {
setConnectionState('idle')
setError(cause instanceof Error ? cause.message : '连接房间失败')
}
}
async function toggleScreenShare() {
if (!roomRef.current) return
try {
await roomRef.current.localParticipant.setScreenShareEnabled(!isSharing)
} catch (cause) {
setError(cause instanceof Error ? cause.message : '无法切换屏幕共享')
}
}
function leaveRoom() {
roomRef.current?.disconnect()
roomRef.current = null
}
function logout() {
leaveRoom()
localStorage.removeItem('screen-share-session')
setSession(null)
setCreatedInvite('')
}
const connected = connectionState === 'connected'
const canShare = roomRole === 'host' || roomRole === 'publisher'
if (!session) {
return (
<main className="app-shell narrow">
<header>
<p className="eyebrow">SCREEN SHARE</p>
<h1>{authMode === 'login' ? '登录' : '使用邀请码注册'}</h1>
</header>
<section className="auth-card">
<label>用户名<input value={username} onChange={(event) => setUsername(event.target.value)} /></label>
<label>密码<input type="password" value={password} onChange={(event) => setPassword(event.target.value)} /></label>
{authMode === 'register' && (
<>
<label>显示名称<input value={displayName} onChange={(event) => setDisplayName(event.target.value)} /></label>
<label>邀请码<input value={inviteCode} onChange={(event) => setInviteCode(event.target.value)} /></label>
</>
)}
<button type="button" className="primary" onClick={() => void submitAuth()}>
{authMode === 'login' ? '登录' : '注册并登录'}
</button>
<button type="button" className="link-button" onClick={() => setAuthMode(authMode === 'login' ? 'register' : 'login')}>
{authMode === 'login' ? '没有账号?使用邀请码注册' : '已有账号?去登录'}
</button>
{error && <p className="error" role="alert">{error}</p>}
</section>
</main>
)
}
return (
<main className="app-shell">
<header className="app-header">
<div>
<p className="eyebrow">LIVEKIT · 已登录</p>
<h1>屏幕共享房间</h1>
<p className="subtitle">当前用户:{session.user.displayName}{session.user.isAdmin ? '管理员' : '普通用户'}</p>
</div>
<button type="button" className="secondary" onClick={logout}>退出登录</button>
</header>
{session.user.isAdmin && (
<section className="admin-panel">
<strong>管理员邀请码</strong>
<button type="button" onClick={() => void createInvite('user')}>创建普通用户邀请码</button>
<button type="button" onClick={() => void createInvite('admin')}>创建管理员邀请码</button>
{createdInvite && <code className="invite-code">{createdInvite}</code>}
</section>
)}
<section className="join-panel room-panel">
<label>房间标题<input list="room-title-history" value={roomTitle} onChange={(event) => setRoomTitle(event.target.value)} disabled={connected} placeholder="输入标题以创建或加入" />
<datalist id="room-title-history">{roomHistory.map((item) => <option key={item.id} value={item.title} />)}</datalist>
</label>
<button type="button" className="primary" onClick={() => void createRoom()} disabled={connected}>创建房间</button>
{!connected
? <button type="button" className="secondary" onClick={() => void joinRoomByTitle()} disabled={connectionState === 'connecting'}>{connectionState === 'connecting' ? '正在连接…' : '按标题加入'}</button>
: <button type="button" className="secondary" onClick={leaveRoom}>离开房间</button>}
<label className="advanced-field">房间 ID(备用)<input value={roomId} onChange={(event) => setRoomId(event.target.value)} disabled={connected} placeholder="UUID" /></label>
{!connected && <button type="button" className="secondary id-join" onClick={() => void connectRoom()} disabled={connectionState === 'connecting'}>使用 ID 加入</button>}
</section>
{roomHistory.length > 0 && (
<section className="room-history">
<strong>最近房间</strong>
{roomHistory.map((item) => (
<button key={item.id} type="button" onClick={() => {
setRoomTitle(item.title)
setRoomId(item.id)
void connectRoom(item.id)
}} disabled={connected}>{item.title}</button>
))}
</section>
)}
{error && <p className="error" role="alert">{error}</p>}
<section className="status-bar" aria-live="polite">
<span className={connected ? 'status online' : 'status'}>{connected ? '已连接' : '未连接'}</span>
<span>{roomRole ? '房间角色:' + roomRole : '尚未加入房间'}</span>
<span>其他参与者:{remoteCount}</span>
{connected && <button type="button" className="share-button" onClick={() => void toggleScreenShare()} disabled={!canShare}>{isSharing ? '停止共享屏幕' : '共享屏幕'}</button>}
</section>
<section className="screens">
<div className="screen-section"><h2>我的屏幕</h2><div className="video-stack">{localScreen ? <ScreenTrack {...localScreen} /> : <p className="empty">尚未共享屏幕</p>}</div></div>
<div className="screen-section"><h2>远端屏幕</h2><div className="video-stack">{remoteScreens.length === 0 ? <p className="empty">等待其他参与者共享屏幕</p> : remoteScreens.map((screen) => <ScreenTrack key={screen.track.sid} {...screen} />)}</div></div>
</section>
</main>
)
}
export default App