Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1d4ef0130 | ||
|
|
c4bc4ef7d1 |
@@ -151,16 +151,17 @@ fun Application.configureRouting() {
|
|||||||
val membership = RoomRepository.member(room.id, user.id)
|
val membership = RoomRepository.member(room.id, user.id)
|
||||||
?: throw ApiException("请先加入房间", 403)
|
?: throw ApiException("请先加入房间", 403)
|
||||||
val liveKit = liveKitSettings()
|
val liveKit = liveKitSettings()
|
||||||
|
val participantIdentity = user.id.toString() + "-" + UUID.randomUUID()
|
||||||
val token = LiveKitTokenIssuer(liveKit.apiKey, liveKit.apiSecret).issue(
|
val token = LiveKitTokenIssuer(liveKit.apiKey, liveKit.apiSecret).issue(
|
||||||
roomName = room.id.toString(),
|
roomName = room.id.toString(),
|
||||||
identity = user.id.toString(),
|
identity = participantIdentity,
|
||||||
participantName = user.displayName,
|
participantName = user.displayName,
|
||||||
role = membership.role,
|
role = membership.role,
|
||||||
)
|
)
|
||||||
call.respond(
|
call.respond(
|
||||||
TokenResponse(
|
TokenResponse(
|
||||||
roomName = room.id.toString(),
|
roomName = room.id.toString(),
|
||||||
participantIdentity = user.id.toString(),
|
participantIdentity = participantIdentity,
|
||||||
participantName = user.displayName,
|
participantName = user.displayName,
|
||||||
livekitUrl = liveKit.publicUrl,
|
livekitUrl = liveKit.publicUrl,
|
||||||
token = token,
|
token = token,
|
||||||
|
|||||||
@@ -8,6 +8,11 @@
|
|||||||
width: min(460px, calc(100% - 32px));
|
width: min(460px, calc(100% - 32px));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-shell.in-room {
|
||||||
|
width: min(1440px, calc(100% - 32px));
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
.app-header {
|
.app-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -240,7 +245,7 @@ button:disabled {
|
|||||||
background: #181a29;
|
background: #181a29;
|
||||||
}
|
}
|
||||||
|
|
||||||
.screen-frame figcaption {
|
.screen-caption {
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
color: #e8e9f4;
|
color: #e8e9f4;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
@@ -253,6 +258,84 @@ button:disabled {
|
|||||||
background: #10111b;
|
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 {
|
.development-note {
|
||||||
margin: 28px auto 0;
|
margin: 28px auto 0;
|
||||||
max-width: 780px;
|
max-width: 780px;
|
||||||
@@ -279,4 +362,12 @@ button:disabled {
|
|||||||
.share-button {
|
.share-button {
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.screen-stage {
|
||||||
|
min-height: 52vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screen-thumbnail {
|
||||||
|
flex-basis: 170px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,12 +22,12 @@ type TokenResponse = {
|
|||||||
role: Role
|
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 }
|
type RoomHistoryItem = { id: string; title: string; visitedAt: string }
|
||||||
|
|
||||||
const ROOM_HISTORY_KEY = 'screen-share-room-history'
|
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)
|
const mediaContainerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -43,10 +43,10 @@ function ScreenTrack({ track, label }: ScreenFeed) {
|
|||||||
}, [track])
|
}, [track])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<figure className="screen-frame">
|
<div className={'screen-frame ' + variant}>
|
||||||
<figcaption>{label}</figcaption>
|
<div className="screen-caption">{label}</div>
|
||||||
<div className="media-slot" ref={mediaContainerRef} />
|
<div className="media-slot" ref={mediaContainerRef} />
|
||||||
</figure>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,9 +90,9 @@ function App() {
|
|||||||
const [connectionState, setConnectionState] = useState<'idle' | 'connecting' | 'connected'>('idle')
|
const [connectionState, setConnectionState] = useState<'idle' | 'connecting' | 'connected'>('idle')
|
||||||
const [roomRole, setRoomRole] = useState<Role | null>(null)
|
const [roomRole, setRoomRole] = useState<Role | null>(null)
|
||||||
const [isSharing, setIsSharing] = useState(false)
|
const [isSharing, setIsSharing] = useState(false)
|
||||||
const [remoteCount, setRemoteCount] = useState(0)
|
|
||||||
const [localScreen, setLocalScreen] = useState<ScreenFeed | null>(null)
|
const [localScreen, setLocalScreen] = useState<ScreenFeed | null>(null)
|
||||||
const [remoteScreens, setRemoteScreens] = useState<ScreenFeed[]>([])
|
const [remoteScreens, setRemoteScreens] = useState<ScreenFeed[]>([])
|
||||||
|
const [activeScreenId, setActiveScreenId] = useState<string | null>(null)
|
||||||
const [createdInvite, setCreatedInvite] = useState('')
|
const [createdInvite, setCreatedInvite] = useState('')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
@@ -114,10 +114,6 @@ function App() {
|
|||||||
setSession(next)
|
setSession(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshRemoteCount(room: Room) {
|
|
||||||
setRemoteCount(room.remoteParticipants.size)
|
|
||||||
}
|
|
||||||
|
|
||||||
function rememberRoom(room: Pick<RoomHistoryItem, 'id' | 'title'>) {
|
function rememberRoom(room: Pick<RoomHistoryItem, 'id' | 'title'>) {
|
||||||
setRoomHistory((history) => {
|
setRoomHistory((history) => {
|
||||||
const next = [
|
const next = [
|
||||||
@@ -224,27 +220,38 @@ function App() {
|
|||||||
setLocalScreen(null)
|
setLocalScreen(null)
|
||||||
setRemoteScreens([])
|
setRemoteScreens([])
|
||||||
const room = new Room()
|
const room = new Room()
|
||||||
room.on(RoomEvent.ParticipantConnected, () => refreshRemoteCount(room))
|
|
||||||
room.on(RoomEvent.ParticipantDisconnected, () => refreshRemoteCount(room))
|
|
||||||
room.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
|
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) => [
|
setRemoteScreens((screens) => [
|
||||||
...screens.filter((screen) => screen.track.sid !== track.sid),
|
...screens.filter((screen) => screen.track.sid !== trackId),
|
||||||
{ track, label: (participant.name || participant.identity) + ' 的屏幕' },
|
{ id: trackId, track, label: (participant.name || participant.identity) + ' 的屏幕', isLocal: false },
|
||||||
])
|
])
|
||||||
|
setActiveScreenId((current) => current?.startsWith('local-') || !current ? trackId : current)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
room.on(RoomEvent.TrackUnsubscribed, (track) => {
|
room.on(RoomEvent.TrackUnsubscribed, (track) => {
|
||||||
setRemoteScreens((screens) => screens.filter((screen) => screen.track !== track))
|
setRemoteScreens((screens) => screens.filter((screen) => screen.track !== track))
|
||||||
|
if (track.sid) {
|
||||||
|
setActiveScreenId((current) => current === track.sid ? null : current)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
room.on(RoomEvent.LocalTrackPublished, (publication) => {
|
room.on(RoomEvent.LocalTrackPublished, (publication) => {
|
||||||
if (publication.source === Track.Source.ScreenShare && publication.track) {
|
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)
|
setIsSharing(true)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
room.on(RoomEvent.LocalTrackUnpublished, (publication) => {
|
room.on(RoomEvent.LocalTrackUnpublished, (publication) => {
|
||||||
if (publication.source === Track.Source.ScreenShare) {
|
if (publication.source === Track.Source.ScreenShare) {
|
||||||
|
setActiveScreenId((current) => current?.startsWith('local-') ? null : current)
|
||||||
setLocalScreen(null)
|
setLocalScreen(null)
|
||||||
setIsSharing(false)
|
setIsSharing(false)
|
||||||
}
|
}
|
||||||
@@ -253,7 +260,6 @@ function App() {
|
|||||||
setConnectionState('idle')
|
setConnectionState('idle')
|
||||||
setRoomRole(null)
|
setRoomRole(null)
|
||||||
setIsSharing(false)
|
setIsSharing(false)
|
||||||
setRemoteCount(0)
|
|
||||||
setLocalScreen(null)
|
setLocalScreen(null)
|
||||||
setRemoteScreens([])
|
setRemoteScreens([])
|
||||||
})
|
})
|
||||||
@@ -262,7 +268,6 @@ function App() {
|
|||||||
roomRef.current = room
|
roomRef.current = room
|
||||||
setRoomId(targetRoomId)
|
setRoomId(targetRoomId)
|
||||||
setRoomRole(payload.role)
|
setRoomRole(payload.role)
|
||||||
refreshRemoteCount(room)
|
|
||||||
setConnectionState('connected')
|
setConnectionState('connected')
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
setConnectionState('idle')
|
setConnectionState('idle')
|
||||||
@@ -293,6 +298,9 @@ function App() {
|
|||||||
|
|
||||||
const connected = connectionState === 'connected'
|
const connected = connectionState === 'connected'
|
||||||
const canShare = roomRole === 'host' || roomRole === 'publisher'
|
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) {
|
if (!session) {
|
||||||
return (
|
return (
|
||||||
@@ -323,7 +331,9 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="app-shell">
|
<main className={connected ? 'app-shell in-room' : 'app-shell'}>
|
||||||
|
{!connected && (
|
||||||
|
<>
|
||||||
<header className="app-header">
|
<header className="app-header">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">LIVEKIT · 已登录</p>
|
<p className="eyebrow">LIVEKIT · 已登录</p>
|
||||||
@@ -343,15 +353,13 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<section className="join-panel room-panel">
|
<section className="join-panel room-panel">
|
||||||
<label>房间标题<input list="room-title-history" value={roomTitle} onChange={(event) => setRoomTitle(event.target.value)} disabled={connected} placeholder="输入标题以创建或加入" />
|
<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>
|
<datalist id="room-title-history">{roomHistory.map((item) => <option key={item.id} value={item.title} />)}</datalist>
|
||||||
</label>
|
</label>
|
||||||
<button type="button" className="primary" onClick={() => void createRoom()} disabled={connected}>创建房间</button>
|
<button type="button" className="primary" onClick={() => void createRoom()}>创建房间</button>
|
||||||
{!connected
|
<button type="button" className="secondary" onClick={() => void joinRoomByTitle()} disabled={connectionState === 'connecting'}>{connectionState === 'connecting' ? '正在连接…' : '按标题加入'}</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" onClick={leaveRoom}>离开房间</button>}
|
<button type="button" className="secondary id-join" onClick={() => void connectRoom()} disabled={connectionState === 'connecting'}>使用 ID 加入</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>
|
||||||
|
|
||||||
{roomHistory.length > 0 && (
|
{roomHistory.length > 0 && (
|
||||||
@@ -362,24 +370,39 @@ function App() {
|
|||||||
setRoomTitle(item.title)
|
setRoomTitle(item.title)
|
||||||
setRoomId(item.id)
|
setRoomId(item.id)
|
||||||
void connectRoom(item.id)
|
void connectRoom(item.id)
|
||||||
}} disabled={connected}>{item.title}</button>
|
}}>{item.title}</button>
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && <p className="error" role="alert">{error}</p>}
|
{error && <p className="error" role="alert">{error}</p>}
|
||||||
|
|
||||||
<section className="status-bar" aria-live="polite">
|
{connected && (
|
||||||
<span className={connected ? 'status online' : 'status'}>{connected ? '已连接' : '未连接'}</span>
|
<>
|
||||||
<span>{roomRole ? '房间角色:' + roomRole : '尚未加入房间'}</span>
|
<section className="room-toolbar" aria-label="房间控制">
|
||||||
<span>其他参与者:{remoteCount}</span>
|
{canShare && <button type="button" className="share-button" onClick={() => void toggleScreenShare()}>{isSharing ? '停止共享屏幕' : '共享屏幕'}</button>}
|
||||||
{connected && <button type="button" className="share-button" onClick={() => void toggleScreenShare()} disabled={!canShare}>{isSharing ? '停止共享屏幕' : '共享屏幕'}</button>}
|
<button type="button" className="secondary" onClick={leaveRoom}>离开房间</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="screens">
|
<section className="screen-stage">
|
||||||
<div className="screen-section"><h2>我的屏幕</h2><div className="video-stack">{localScreen ? <ScreenTrack {...localScreen} /> : <p className="empty">尚未共享屏幕</p>}</div></div>
|
{activeScreen
|
||||||
<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>
|
? <ScreenTrack {...activeScreen} variant="main" />
|
||||||
|
: <p className="empty stage-empty">等待参与者共享屏幕</p>}
|
||||||
</section>
|
</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>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user