Compare commits

..
2 Commits
Author SHA1 Message Date
OrangeRoll a1d4ef0130 tag(功能):前端屏幕显示变化 2026-08-07 14:27:36 +08:00
OrangeRoll c4bc4ef7d1 tag(功能):同一帐号可以多处登录 2026-08-07 14:18:08 +08:00
3 changed files with 185 additions and 70 deletions
@@ -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,
+92 -1
View File
@@ -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;
}
}
+90 -67
View File
@@ -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>
)
}