Compare commits
2
Commits
d00848ee36
...
a1d4ef0130
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1d4ef0130 | ||
|
|
c4bc4ef7d1 |
@@ -151,16 +151,17 @@ fun Application.configureRouting() {
|
||||
val membership = RoomRepository.member(room.id, user.id)
|
||||
?: throw ApiException("请先加入房间", 403)
|
||||
val liveKit = liveKitSettings()
|
||||
val participantIdentity = user.id.toString() + "-" + UUID.randomUUID()
|
||||
val token = LiveKitTokenIssuer(liveKit.apiKey, liveKit.apiSecret).issue(
|
||||
roomName = room.id.toString(),
|
||||
identity = user.id.toString(),
|
||||
identity = participantIdentity,
|
||||
participantName = user.displayName,
|
||||
role = membership.role,
|
||||
)
|
||||
call.respond(
|
||||
TokenResponse(
|
||||
roomName = room.id.toString(),
|
||||
participantIdentity = user.id.toString(),
|
||||
participantIdentity = participantIdentity,
|
||||
participantName = user.displayName,
|
||||
livekitUrl = liveKit.publicUrl,
|
||||
token = token,
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
width: min(460px, calc(100% - 32px));
|
||||
}
|
||||
|
||||
.app-shell.in-room {
|
||||
width: min(1440px, calc(100% - 32px));
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -240,7 +245,7 @@ button:disabled {
|
||||
background: #181a29;
|
||||
}
|
||||
|
||||
.screen-frame figcaption {
|
||||
.screen-caption {
|
||||
padding: 8px 10px;
|
||||
color: #e8e9f4;
|
||||
font-size: 0.82rem;
|
||||
@@ -253,6 +258,84 @@ button:disabled {
|
||||
background: #10111b;
|
||||
}
|
||||
|
||||
.room-toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.room-toolbar .share-button {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.screen-stage {
|
||||
display: grid;
|
||||
min-height: min(72vh, 760px);
|
||||
overflow: hidden;
|
||||
border: 1px solid #dedfed;
|
||||
border-radius: 16px;
|
||||
background: #10111b;
|
||||
}
|
||||
|
||||
.screen-stage .screen-frame {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.screen-stage .media-slot,
|
||||
.screen-stage .media-slot video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: none;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.stage-empty {
|
||||
margin: auto;
|
||||
color: #c3c5d5;
|
||||
}
|
||||
|
||||
.screen-thumbnails {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
overflow-x: auto;
|
||||
padding: 14px 2px 2px;
|
||||
}
|
||||
|
||||
.screen-thumbnail {
|
||||
flex: 0 0 230px;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 10px;
|
||||
background: #181a29;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.screen-thumbnail:hover,
|
||||
.screen-thumbnail:focus-visible {
|
||||
border-color: #7669ed;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.screen-thumbnail .screen-frame {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.screen-thumbnail .screen-caption {
|
||||
padding: 6px 8px;
|
||||
font-size: 0.73rem;
|
||||
}
|
||||
|
||||
.screen-thumbnail .media-slot video {
|
||||
height: 128px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.development-note {
|
||||
margin: 28px auto 0;
|
||||
max-width: 780px;
|
||||
@@ -279,4 +362,12 @@ button:disabled {
|
||||
.share-button {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.screen-stage {
|
||||
min-height: 52vh;
|
||||
}
|
||||
|
||||
.screen-thumbnail {
|
||||
flex-basis: 170px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,12 +22,12 @@ type TokenResponse = {
|
||||
role: Role
|
||||
}
|
||||
|
||||
type ScreenFeed = { track: Track; label: string }
|
||||
type ScreenFeed = { id: string; track: Track; label: string; isLocal: boolean }
|
||||
type RoomHistoryItem = { id: string; title: string; visitedAt: string }
|
||||
|
||||
const ROOM_HISTORY_KEY = 'screen-share-room-history'
|
||||
|
||||
function ScreenTrack({ track, label }: ScreenFeed) {
|
||||
function ScreenTrack({ track, label, variant = 'main' }: ScreenFeed & { variant?: 'main' | 'thumbnail' }) {
|
||||
const mediaContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -43,10 +43,10 @@ function ScreenTrack({ track, label }: ScreenFeed) {
|
||||
}, [track])
|
||||
|
||||
return (
|
||||
<figure className="screen-frame">
|
||||
<figcaption>{label}</figcaption>
|
||||
<div className={'screen-frame ' + variant}>
|
||||
<div className="screen-caption">{label}</div>
|
||||
<div className="media-slot" ref={mediaContainerRef} />
|
||||
</figure>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -90,9 +90,9 @@ function App() {
|
||||
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 [activeScreenId, setActiveScreenId] = useState<string | null>(null)
|
||||
const [createdInvite, setCreatedInvite] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
@@ -114,10 +114,6 @@ function App() {
|
||||
setSession(next)
|
||||
}
|
||||
|
||||
function refreshRemoteCount(room: Room) {
|
||||
setRemoteCount(room.remoteParticipants.size)
|
||||
}
|
||||
|
||||
function rememberRoom(room: Pick<RoomHistoryItem, 'id' | 'title'>) {
|
||||
setRoomHistory((history) => {
|
||||
const next = [
|
||||
@@ -224,27 +220,38 @@ function App() {
|
||||
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) {
|
||||
const trackId = track.sid
|
||||
if (track.kind === Track.Kind.Video && publication.source === Track.Source.ScreenShare && trackId) {
|
||||
setRemoteScreens((screens) => [
|
||||
...screens.filter((screen) => screen.track.sid !== track.sid),
|
||||
{ track, label: (participant.name || participant.identity) + ' 的屏幕' },
|
||||
...screens.filter((screen) => screen.track.sid !== trackId),
|
||||
{ id: trackId, track, label: (participant.name || participant.identity) + ' 的屏幕', isLocal: false },
|
||||
])
|
||||
setActiveScreenId((current) => current?.startsWith('local-') || !current ? trackId : current)
|
||||
}
|
||||
})
|
||||
room.on(RoomEvent.TrackUnsubscribed, (track) => {
|
||||
setRemoteScreens((screens) => screens.filter((screen) => screen.track !== track))
|
||||
if (track.sid) {
|
||||
setActiveScreenId((current) => current === track.sid ? null : current)
|
||||
}
|
||||
})
|
||||
room.on(RoomEvent.LocalTrackPublished, (publication) => {
|
||||
if (publication.source === Track.Source.ScreenShare && publication.track) {
|
||||
setLocalScreen({ track: publication.track, label: '你正在共享的屏幕' })
|
||||
const localScreenId = 'local-' + publication.track.sid
|
||||
setLocalScreen({
|
||||
id: localScreenId,
|
||||
track: publication.track,
|
||||
label: '你正在共享的屏幕',
|
||||
isLocal: true,
|
||||
})
|
||||
setActiveScreenId((current) => current ?? localScreenId)
|
||||
setIsSharing(true)
|
||||
}
|
||||
})
|
||||
room.on(RoomEvent.LocalTrackUnpublished, (publication) => {
|
||||
if (publication.source === Track.Source.ScreenShare) {
|
||||
setActiveScreenId((current) => current?.startsWith('local-') ? null : current)
|
||||
setLocalScreen(null)
|
||||
setIsSharing(false)
|
||||
}
|
||||
@@ -253,7 +260,6 @@ function App() {
|
||||
setConnectionState('idle')
|
||||
setRoomRole(null)
|
||||
setIsSharing(false)
|
||||
setRemoteCount(0)
|
||||
setLocalScreen(null)
|
||||
setRemoteScreens([])
|
||||
})
|
||||
@@ -262,7 +268,6 @@ function App() {
|
||||
roomRef.current = room
|
||||
setRoomId(targetRoomId)
|
||||
setRoomRole(payload.role)
|
||||
refreshRemoteCount(room)
|
||||
setConnectionState('connected')
|
||||
} catch (cause) {
|
||||
setConnectionState('idle')
|
||||
@@ -293,6 +298,9 @@ function App() {
|
||||
|
||||
const connected = connectionState === 'connected'
|
||||
const canShare = roomRole === 'host' || roomRole === 'publisher'
|
||||
const screens = [...(localScreen ? [localScreen] : []), ...remoteScreens]
|
||||
const activeScreen = screens.find((screen) => screen.id === activeScreenId) ?? screens[0]
|
||||
const secondaryScreens = screens.filter((screen) => screen.id !== activeScreen?.id)
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
@@ -323,63 +331,78 @@ function App() {
|
||||
}
|
||||
|
||||
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>
|
||||
<main className={connected ? 'app-shell in-room' : 'app-shell'}>
|
||||
{!connected && (
|
||||
<>
|
||||
<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>
|
||||
)}
|
||||
{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>
|
||||
<section className="join-panel room-panel">
|
||||
<label>房间标题<input list="room-title-history" value={roomTitle} onChange={(event) => setRoomTitle(event.target.value)} 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()}>创建房间</button>
|
||||
<button type="button" className="secondary" onClick={() => void joinRoomByTitle()} disabled={connectionState === 'connecting'}>{connectionState === 'connecting' ? '正在连接…' : '按标题加入'}</button>
|
||||
<label className="advanced-field">房间 ID(备用)<input value={roomId} onChange={(event) => setRoomId(event.target.value)} placeholder="UUID" /></label>
|
||||
<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>
|
||||
{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)
|
||||
}}>{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>
|
||||
{connected && (
|
||||
<>
|
||||
<section className="room-toolbar" aria-label="房间控制">
|
||||
{canShare && <button type="button" className="share-button" onClick={() => void toggleScreenShare()}>{isSharing ? '停止共享屏幕' : '共享屏幕'}</button>}
|
||||
<button type="button" className="secondary" onClick={leaveRoom}>离开房间</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>
|
||||
<section className="screen-stage">
|
||||
{activeScreen
|
||||
? <ScreenTrack {...activeScreen} variant="main" />
|
||||
: <p className="empty stage-empty">等待参与者共享屏幕</p>}
|
||||
</section>
|
||||
|
||||
{secondaryScreens.length > 0 && (
|
||||
<section className="screen-thumbnails" aria-label="副屏幕分享">
|
||||
{secondaryScreens.map((screen) => (
|
||||
<button key={screen.id} type="button" className="screen-thumbnail" onClick={() => setActiveScreenId(screen.id)}>
|
||||
<ScreenTrack {...screen} variant="thumbnail" />
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user