388 lines
14 KiB
TypeScript
388 lines
14 KiB
TypeScript
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
|