feat(voice): add real-time voice room with LiveKit
- LiveKit WebRTC SFU container in docker-compose - Voice token microservice (Node.js + Express) - VoiceRoom page with member grid and controls - useVoiceRoom composable for LiveKit connection - Voice entry button in TeamSessionPanel - Nginx proxy for voice-token service API Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
# Dev Environment
|
||||
VITE_PB_URL=http://192.168.1.14:8711
|
||||
VITE_PORT=7033
|
||||
VITE_LIVEKIT_URL=ws://192.168.1.14:7880
|
||||
VITE_VOICE_TOKEN_URL=http://192.168.1.14:7882
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# UAT Environment
|
||||
VITE_PB_URL=http://192.168.1.14:8711
|
||||
VITE_PORT=7034
|
||||
VITE_LIVEKIT_URL=ws://192.168.1.14:7880
|
||||
VITE_VOICE_TOKEN_URL=http://192.168.1.14:7882
|
||||
|
||||
@@ -30,6 +30,15 @@ server {
|
||||
gzip off;
|
||||
}
|
||||
|
||||
# Voice token service proxy
|
||||
location /voice-api/ {
|
||||
proxy_pass http://192.168.1.14:7882/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# API 代理到局域网 PocketBase
|
||||
location /api/ {
|
||||
client_max_body_size 500m;
|
||||
|
||||
@@ -30,6 +30,15 @@ server {
|
||||
gzip off;
|
||||
}
|
||||
|
||||
# Voice token service proxy
|
||||
location /voice-api/ {
|
||||
proxy_pass http://192.168.1.14:7882/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# API 代理到局域网 PocketBase
|
||||
location /api/ {
|
||||
proxy_pass http://192.168.1.14:8712;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// src/api/voice.ts
|
||||
import { pb } from './pocketbase'
|
||||
|
||||
const LIVEKIT_URL = import.meta.env.VITE_LIVEKIT_URL || 'ws://192.168.1.14:7880'
|
||||
|
||||
export function getLiveKitUrl(): string {
|
||||
return LIVEKIT_URL
|
||||
}
|
||||
|
||||
export async function fetchVoiceToken(sessionId: string): Promise<string> {
|
||||
const user = pb.authStore.model
|
||||
if (!user) throw new Error('未登录')
|
||||
|
||||
const token = pb.authStore.token
|
||||
|
||||
const res = await fetch(`/voice-api/voice-token/${sessionId}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: token,
|
||||
},
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({ error: '语音服务暂不可用' }))
|
||||
throw new Error(data.error || '语音服务暂不可用')
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
return data.token
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
<!-- src/components/team/TeamSessionPanel.vue -->
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useTeamStore } from '@/stores/team'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
@@ -15,6 +16,7 @@ const userStore = useUserStore()
|
||||
const session = computed(() => teamStore.currentSession)
|
||||
const statusText = computed(() => session.value ? TeamStatusMap[session.value.status] : '')
|
||||
const showGameSelect = ref(false)
|
||||
const route = useRoute()
|
||||
|
||||
const memberDetails = computed(() => {
|
||||
if (!session.value) return []
|
||||
@@ -114,6 +116,13 @@ async function handleGameSelected(gameName: string) {
|
||||
<button v-else-if="session.status === 'playing'" class="end-game-btn" @click="endGame">
|
||||
游戏结束
|
||||
</button>
|
||||
<router-link
|
||||
v-if="session.status === 'recruiting' || session.status === 'playing'"
|
||||
:to="`/group/${route.params.id}/voice/${session.id}`"
|
||||
class="voice-btn"
|
||||
>
|
||||
语音房间
|
||||
</router-link>
|
||||
<button v-if="session.status === 'recruiting'" class="dissolve-btn" @click="endGame">
|
||||
解散小组
|
||||
</button>
|
||||
@@ -315,4 +324,25 @@ async function handleGameSelected(gameName: string) {
|
||||
box-shadow: 0 4px 20px rgba(5, 150, 105, 0.3);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.voice-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
border: 1px solid var(--gg-primary);
|
||||
border-radius: var(--gg-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--gg-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.voice-btn:hover {
|
||||
background: rgba(5, 150, 105, 0.1);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<!-- src/components/voice/VoiceControls.vue -->
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
micEnabled: boolean
|
||||
speakerEnabled: boolean
|
||||
connected: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggleMic: []
|
||||
toggleSpeaker: []
|
||||
leave: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="voice-controls">
|
||||
<button
|
||||
class="ctrl-btn"
|
||||
:class="{ active: micEnabled, off: !micEnabled }"
|
||||
@click="emit('toggleMic')"
|
||||
>
|
||||
<span class="ctrl-icon">{{ micEnabled ? '🎤' : '🔇' }}</span>
|
||||
<span class="ctrl-label">麦克风</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="ctrl-btn"
|
||||
:class="{ active: speakerEnabled, off: !speakerEnabled }"
|
||||
@click="emit('toggleSpeaker')"
|
||||
>
|
||||
<span class="ctrl-icon">{{ speakerEnabled ? '🔊' : '🔈' }}</span>
|
||||
<span class="ctrl-label">扬声器</span>
|
||||
</button>
|
||||
|
||||
<button class="ctrl-btn leave-btn" @click="emit('leave')">
|
||||
<span class="ctrl-icon">🚪</span>
|
||||
<span class="ctrl-label">离开</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.voice-controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
padding: 20px;
|
||||
background: var(--gg-bg-card);
|
||||
border-top: 1px solid var(--gg-border);
|
||||
}
|
||||
|
||||
.ctrl-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 12px 24px;
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-md);
|
||||
background: var(--gg-bg);
|
||||
color: var(--gg-text);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.ctrl-btn:hover {
|
||||
border-color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.ctrl-btn.off {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.ctrl-btn.active {
|
||||
border-color: var(--gg-primary);
|
||||
background: rgba(5, 150, 105, 0.1);
|
||||
}
|
||||
|
||||
.ctrl-icon {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.ctrl-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.leave-btn {
|
||||
border-color: var(--gg-danger);
|
||||
color: var(--gg-danger);
|
||||
}
|
||||
|
||||
.leave-btn:hover {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,101 @@
|
||||
<!-- src/components/voice/VoiceMemberGrid.vue -->
|
||||
<script setup lang="ts">
|
||||
import type { VoiceParticipant } from '@/composables/useVoiceRoom'
|
||||
|
||||
defineProps<{
|
||||
participants: Map<string, VoiceParticipant>
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="voice-member-grid">
|
||||
<div
|
||||
v-for="[id, p] of participants"
|
||||
:key="id"
|
||||
class="voice-member"
|
||||
:class="{ speaking: p.isSpeaking, muted: p.isMuted }"
|
||||
>
|
||||
<div class="avatar-ring">
|
||||
<img
|
||||
:src="'/default-avatar.svg'"
|
||||
:alt="p.name"
|
||||
class="avatar"
|
||||
/>
|
||||
</div>
|
||||
<span class="name">{{ p.name }}</span>
|
||||
<span v-if="p.isMuted" class="mic-status muted">静音</span>
|
||||
<span v-else-if="p.isSpeaking" class="mic-status active">说话中</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.voice-member-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
gap: 16px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.voice-member {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.avatar-ring {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
padding: 3px;
|
||||
border: 2px solid var(--gg-border);
|
||||
transition: border-color 0.2s, box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.speaking .avatar-ring {
|
||||
border-color: var(--gg-primary);
|
||||
box-shadow: 0 0 16px rgba(5, 150, 105, 0.4);
|
||||
animation: pulse-ring 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.muted .avatar-ring {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@keyframes pulse-ring {
|
||||
0%, 100% { box-shadow: 0 0 8px rgba(5, 150, 105, 0.2); }
|
||||
50% { box-shadow: 0 0 20px rgba(5, 150, 105, 0.5); }
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--gg-text);
|
||||
text-align: center;
|
||||
max-width: 90px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mic-status {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mic-status.active {
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.mic-status.muted {
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,117 @@
|
||||
// src/composables/useVoiceRoom.ts
|
||||
import { ref } from 'vue'
|
||||
import { Room, RoomEvent, Participant, RemoteTrackPublication } from 'livekit-client'
|
||||
import { fetchVoiceToken, getLiveKitUrl } from '@/api/voice'
|
||||
|
||||
export interface VoiceParticipant {
|
||||
identity: string
|
||||
name: string
|
||||
isSpeaking: boolean
|
||||
isMuted: boolean
|
||||
}
|
||||
|
||||
export function useVoiceRoom() {
|
||||
const room = ref<Room | null>(null)
|
||||
const connected = ref(false)
|
||||
const participants = ref<Map<string, VoiceParticipant>>(new Map())
|
||||
const micEnabled = ref(true)
|
||||
const speakerEnabled = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
function syncParticipant(participant: Participant) {
|
||||
const audioPub = participant.audioTrackPublications.values().next().value
|
||||
participants.value.set(participant.identity, {
|
||||
identity: participant.identity,
|
||||
name: participant.name || participant.identity,
|
||||
isSpeaking: participant.isSpeaking,
|
||||
isMuted: audioPub?.isMuted ?? true,
|
||||
})
|
||||
participants.value = new Map(participants.value)
|
||||
}
|
||||
|
||||
async function connect(sessionId: string) {
|
||||
try {
|
||||
error.value = null
|
||||
const token = await fetchVoiceToken(sessionId)
|
||||
const livekitUrl = getLiveKitUrl()
|
||||
|
||||
const newRoom = new Room()
|
||||
|
||||
newRoom.on(RoomEvent.ParticipantConnected, syncParticipant)
|
||||
newRoom.on(RoomEvent.ParticipantDisconnected, (p) => {
|
||||
participants.value.delete(p.identity)
|
||||
participants.value = new Map(participants.value)
|
||||
})
|
||||
newRoom.on(RoomEvent.ActiveSpeakersChanged, (speakers) => {
|
||||
const ids = new Set(speakers.map(s => s.identity))
|
||||
for (const [id, p] of participants.value) {
|
||||
p.isSpeaking = ids.has(id)
|
||||
}
|
||||
participants.value = new Map(participants.value)
|
||||
})
|
||||
newRoom.on(RoomEvent.TrackMuted, (_pub, p) => syncParticipant(p))
|
||||
newRoom.on(RoomEvent.TrackUnmuted, (_pub, p) => syncParticipant(p))
|
||||
newRoom.on(RoomEvent.TrackSubscribed, (_track, _pub, p) => syncParticipant(p))
|
||||
newRoom.on(RoomEvent.TrackUnsubscribed, (_track, _pub, p) => syncParticipant(p))
|
||||
|
||||
await newRoom.connect(livekitUrl, token)
|
||||
|
||||
syncParticipant(newRoom.localParticipant as unknown as Participant)
|
||||
for (const p of newRoom.remoteParticipants.values()) {
|
||||
syncParticipant(p as unknown as Participant)
|
||||
}
|
||||
|
||||
await newRoom.localParticipant.setMicrophoneEnabled(true)
|
||||
|
||||
room.value = newRoom
|
||||
connected.value = true
|
||||
} catch (e: any) {
|
||||
error.value = e.message || '连接语音房间失败'
|
||||
console.error('Voice room connect error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleMic() {
|
||||
if (!room.value) return
|
||||
const enabled = !micEnabled.value
|
||||
await room.value.localParticipant.setMicrophoneEnabled(enabled)
|
||||
micEnabled.value = enabled
|
||||
syncParticipant(room.value.localParticipant as unknown as Participant)
|
||||
}
|
||||
|
||||
async function toggleSpeaker() {
|
||||
if (!room.value) return
|
||||
speakerEnabled.value = !speakerEnabled.value
|
||||
for (const p of room.value.remoteParticipants.values()) {
|
||||
for (const pub of p.audioTrackPublications.values()) {
|
||||
if (pub instanceof RemoteTrackPublication) {
|
||||
pub.setEnabled(speakerEnabled.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnect() {
|
||||
if (room.value) {
|
||||
await room.value.disconnect()
|
||||
room.value = null
|
||||
}
|
||||
connected.value = false
|
||||
participants.value = new Map()
|
||||
micEnabled.value = true
|
||||
speakerEnabled.value = true
|
||||
}
|
||||
|
||||
return {
|
||||
room,
|
||||
connected,
|
||||
participants,
|
||||
micEnabled,
|
||||
speakerEnabled,
|
||||
error,
|
||||
connect,
|
||||
disconnect,
|
||||
toggleMic,
|
||||
toggleSpeaker,
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,13 @@ const routes: RouteRecordRaw[] = [
|
||||
props: true,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'group/:groupId/voice/:sessionId',
|
||||
name: 'VoiceRoom',
|
||||
component: () => import('@/views/VoiceRoom.vue'),
|
||||
props: true,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'games',
|
||||
name: 'GamesLibrary',
|
||||
|
||||
@@ -85,6 +85,8 @@ export interface TeamSession {
|
||||
members: string[]
|
||||
status: TeamStatus
|
||||
dissolvedAt?: string
|
||||
voiceRoom?: string
|
||||
voiceActive?: boolean
|
||||
created: string
|
||||
updated: string
|
||||
expand?: {
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
<!-- src/views/VoiceRoom.vue -->
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useTeamStore } from '@/stores/team'
|
||||
import { useVoiceRoom } from '@/composables/useVoiceRoom'
|
||||
import { pb } from '@/api/pocketbase'
|
||||
import VoiceMemberGrid from '@/components/voice/VoiceMemberGrid.vue'
|
||||
import VoiceControls from '@/components/voice/VoiceControls.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const teamStore = useTeamStore()
|
||||
|
||||
const sessionId = route.params.sessionId as string
|
||||
const groupId = route.params.groupId as string
|
||||
|
||||
const session = computed(() => teamStore.currentSession)
|
||||
const gameName = computed(() => session.value?.gameName || '语音房间')
|
||||
|
||||
const {
|
||||
connected,
|
||||
participants,
|
||||
micEnabled,
|
||||
speakerEnabled,
|
||||
error,
|
||||
connect,
|
||||
disconnect,
|
||||
toggleMic,
|
||||
toggleSpeaker,
|
||||
} = useVoiceRoom()
|
||||
|
||||
onMounted(async () => {
|
||||
if (!teamStore.currentSession || teamStore.currentSession.id !== sessionId) {
|
||||
await teamStore.loadActiveSession()
|
||||
}
|
||||
|
||||
const userId = pb.authStore.model?.id
|
||||
if (!userId) {
|
||||
router.replace('/login')
|
||||
return
|
||||
}
|
||||
|
||||
await connect(sessionId)
|
||||
})
|
||||
|
||||
onUnmounted(async () => {
|
||||
await disconnect()
|
||||
})
|
||||
|
||||
async function handleLeave() {
|
||||
await disconnect()
|
||||
router.replace(`/group/${groupId}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="voice-room">
|
||||
<header class="voice-header">
|
||||
<button class="back-btn" @click="handleLeave">← 返回</button>
|
||||
<h1 class="room-title">{{ gameName }}</h1>
|
||||
<div class="conn-status" :class="{ on: connected }">
|
||||
{{ connected ? '已连接' : '连接中...' }}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="error" class="error-banner">{{ error }}</div>
|
||||
|
||||
<main class="voice-body">
|
||||
<VoiceMemberGrid :participants="participants" />
|
||||
<div v-if="participants.size === 0" class="empty-hint">正在连接语音...</div>
|
||||
</main>
|
||||
|
||||
<VoiceControls
|
||||
:mic-enabled="micEnabled"
|
||||
:speaker-enabled="speakerEnabled"
|
||||
:connected="connected"
|
||||
@toggle-mic="toggleMic"
|
||||
@toggle-speaker="toggleSpeaker"
|
||||
@leave="handleLeave"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.voice-room {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: var(--gg-bg);
|
||||
}
|
||||
|
||||
.voice-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 24px;
|
||||
background: var(--gg-bg-card);
|
||||
border-bottom: 1px solid var(--gg-border);
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--gg-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
border-color: var(--gg-primary);
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.room-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.conn-status {
|
||||
font-size: 13px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
background: var(--gg-bg);
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
.conn-status.on {
|
||||
background: rgba(5, 150, 105, 0.15);
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
margin: 16px 24px;
|
||||
padding: 12px 16px;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid var(--gg-danger);
|
||||
border-radius: var(--gg-radius-sm);
|
||||
color: var(--gg-danger);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.voice-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
color: var(--gg-text-muted);
|
||||
font-size: 15px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user