Files
gamegroup2/docs/plans/2026-04-19-voice-room-implementation.md
T
congsh 5d434ead6f 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>
2026-04-19 22:24:28 +08:00

21 KiB
Raw Blame History

语音房间功能 Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: 在组队功能中集成 LiveKit 实时语音通话,提供独立语音房间页面。

Architecture: LiveKit Server (Docker) 提供 WebRTC SFU 服务。PocketBase JS hook 签发 LiveKit token。前端用 livekit-client + @livekit/components-vue 实现语音房间 UI。

Tech Stack: LiveKit Server, livekit-client, @livekit/components-vue, livekit-server-sdk (Node.js)


Task 1: Docker 基础设施 — 添加 LiveKit 容器

Files:

  • Modify: docker-compose.backend.yml
  • Modify: docker-compose.uat.yml

Step 1: 在 docker-compose.backend.yml 添加 LiveKit 服务

在 pocketbase 服务之后、networks 之前添加:

  livekit:
    image: livekit/livekit-server:v1.7
    container_name: gamegroup-livekit
    ports:
      - "7880:7880"
      - "7881:7881/udp"
    environment:
      - LIVEKIT_KEYS="APIyxZGQjM2: secretNmU4ZDU3YjA0OWIxNDM4YjhlNWY3YTFjZGUzOWRi"
    command: --dev
    restart: unless-stopped
    networks:
      - gamegroup-net

Step 2: 在 docker-compose.uat.yml 同步添加

在 pocketbase-uat 服务之后、frontend-uat 之前添加同样的 LiveKit 服务,端口相同(UAT 和 Dev 共享同一台机器的 Docker 网络)。

Step 3: 启动验证

Run: docker compose -f docker-compose.backend.yml up -d livekit Expected: 容器正常启动,docker logs gamegroup-livekit 显示 LiveKit listening on :7880

Step 4: Commit

git add docker-compose.backend.yml docker-compose.uat.yml
git commit -m "feat(voice): add LiveKit server container to docker-compose"

Task 2: 安装前端 LiveKit 依赖

Files:

  • Modify: frontend/package.json

Step 1: 安装依赖

Run: cd frontend && npm install livekit-client @livekit/components-vue

Step 2: 验证安装

Run: cd frontend && npm ls livekit-client @livekit/components-vue Expected: 两个包正常列出

Step 3: Commit

git add frontend/package.json frontend/package-lock.json
git commit -m "feat(voice): add livekit-client and components-vue dependencies"

Task 3: 前端 API 层 — voice.ts

Files:

  • Create: frontend/src/api/voice.ts

Step 1: 创建 voice.ts API 封装

// 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('未登录')

  // 先验证用户是该 session 的成员
  const session = await pb.collection('team_sessions').getOne(sessionId)
  const members: string[] = (session as any).members || []
  if (!members.includes(user.id)) {
    throw new Error('你不是该小队的成员')
  }

  // 调用 PocketBase hook 获取 token
  // 如果 hook 未部署,用前端 fallback 方案(仅开发用)
  try {
    const res = await pb.send(`/api/voice-token/${sessionId}`, {
      method: 'POST',
    })
    return res.token
  } catch {
    // Hook 不可用时,提示用户
    throw new Error('语音服务暂不可用,请检查 LiveKit 配置')
  }
}

Step 2: 添加 .env 配置

frontend/.env.devfrontend/.env.uat 中添加:

VITE_LIVEKIT_URL=ws://192.168.1.14:7880

Step 3: Commit

git add frontend/src/api/voice.ts frontend/.env.dev frontend/.env.uat
git commit -m "feat(voice): add voice API layer with LiveKit token fetch"

Task 4: 类型更新 — TeamSession 加语音字段

Files:

  • Modify: frontend/src/types/index.ts:80-94

Step 1: 在 TeamSession interface 中添加字段

updated: string 之后、expand? 之前添加:

  voiceRoom?: string
  voiceActive?: boolean

Step 2: Commit

git add frontend/src/types/index.ts
git commit -m "feat(voice): add voiceRoom and voiceActive fields to TeamSession type"

Task 5: composable — useVoiceRoom.ts

Files:

  • Create: frontend/src/composables/useVoiceRoom.ts

Step 1: 创建 useVoiceRoom composable

// src/composables/useVoiceRoom.ts
import { ref, onUnmounted } from 'vue'
import { Room, RoomEvent, Track, RemoteParticipant, LocalParticipant } from 'livekit-client'
import { fetchVoiceToken, getLiveKitUrl } from '@/api/voice'

export interface VoiceParticipant {
  identity: string
  name: string
  isSpeaking: boolean
  isMuted: boolean
  avatar?: string
}

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)

  async function connect(sessionId: string, userId: string) {
    try {
      error.value = null
      const token = await fetchVoiceToken(sessionId)
      const livekitUrl = getLiveKitUrl()

      const newRoom = new Room()
      
      newRoom.on(RoomEvent.TrackSubscribed, (_track, pub, participant) => {
        updateParticipant(participant)
      })

      newRoom.on(RoomEvent.TrackUnsubscribed, (_track, pub, participant) => {
        updateParticipant(participant)
      })

      newRoom.on(RoomEvent.ParticipantConnected, (participant) => {
        updateParticipant(participant)
      })

      newRoom.on(RoomEvent.ParticipantDisconnected, (participant) => {
        participants.value.delete(participant.identity)
        participants.value = new Map(participants.value)
      })

      newRoom.on(RoomEvent.ActiveSpeakersChanged, (speakers) => {
        const speakerIds = new Set(speakers.map(s => s.identity))
        for (const [id, p] of participants.value) {
          p.isSpeaking = speakerIds.has(id)
        }
        participants.value = new Map(participants.value)
      })

      newRoom.on(RoomEvent.TrackMuted, (pub, participant) => {
        updateParticipant(participant)
      })

      newRoom.on(RoomEvent.TrackUnmuted, (pub, participant) => {
        updateParticipant(participant)
      })

      await newRoom.connect(livekitUrl, token)

      // 添加本地参与者
      updateParticipant(newRoom.localParticipant)

      // 添加已有远端参与者
      for (const p of newRoom.remoteParticipants.values()) {
        updateParticipant(p)
      }

      // 发布本地麦克风
      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)
    }
  }

  function updateParticipant(participant: LocalParticipant | RemoteParticipant) {
    const isLocal = participant instanceof LocalParticipant
    const audioTrack = isLocal
      ? participant.audioTrackPublications.values().next().value
      : participant.audioTrackPublications.values().next().value

    const vp: VoiceParticipant = {
      identity: participant.identity,
      name: participant.name || participant.identity,
      isSpeaking: participant.isSpeaking,
      isMuted: audioTrack?.isMuted ?? true,
    }

    participants.value.set(participant.identity, vp)
    participants.value = new Map(participants.value)
  }

  async function toggleMic() {
    if (!room.value) return
    const enabled = !micEnabled.value
    await room.value.localParticipant.setMicrophoneEnabled(enabled)
    micEnabled.value = enabled
    updateParticipant(room.value.localParticipant)
  }

  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.track) {
          pub.track.enabled = 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,
  }
}

Step 2: Commit

git add frontend/src/composables/useVoiceRoom.ts
git commit -m "feat(voice): add useVoiceRoom composable with LiveKit connection management"

Task 6: 语音组件 — VoiceMemberGrid + VoiceControls

Files:

  • Create: frontend/src/components/voice/VoiceMemberGrid.vue
  • Create: frontend/src/components/voice/VoiceControls.vue

Step 1: 创建 VoiceMemberGrid.vue

成员头像网格,说话时有绿圈呼吸动画。

<!-- src/components/voice/VoiceMemberGrid.vue -->
<script setup lang="ts">
import type { VoiceParticipant } from '@/composables/useVoiceRoom'
import { displayName } from '@/types'

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="p.avatar || '/default-avatar.svg'"
          :alt="p.name"
          class="avatar"
        />
      </div>
      <span class="name">{{ p.name }}</span>
      <span v-if="p.isMuted" class="mic-icon muted">🎤✕</span>
      <span v-else-if="p.isSpeaking" class="mic-icon 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-icon {
  font-size: 11px;
}

.mic-icon.active {
  color: var(--gg-primary);
}

.mic-icon.muted {
  color: var(--gg-text-muted);
}
</style>

Step 2: 创建 VoiceControls.vue

底部控制栏。

<!-- 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 {
  background: var(--gg-bg);
  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);
  border-color: var(--gg-danger);
}
</style>

Step 3: Commit

git add frontend/src/components/voice/VoiceMemberGrid.vue frontend/src/components/voice/VoiceControls.vue
git commit -m "feat(voice): add VoiceMemberGrid and VoiceControls components"

Task 7: 语音房间页面 — VoiceRoom.vue

Files:

  • Create: frontend/src/views/VoiceRoom.vue
  • Modify: frontend/src/router/index.ts

Step 1: 创建 VoiceRoom.vue

<!-- 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 { useGroupStore } from '@/stores/group'
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 groupStore = useGroupStore()

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 () => {
  // 加载 session 数据
  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, userId)
})

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>

Step 2: 添加路由

frontend/src/router/index.ts 中,在 blacklist 路由之后、games 路由之前添加:

      {
        path: 'group/:groupId/voice/:sessionId',
        name: 'VoiceRoom',
        component: () => import('@/views/VoiceRoom.vue'),
        props: true,
        meta: { requiresAuth: true }
      },

Step 3: Commit

git add frontend/src/views/VoiceRoom.vue frontend/src/router/index.ts
git commit -m "feat(voice): add VoiceRoom page and route"

Task 8: 入口按钮 — TeamSessionPanel 加语音入口

Files:

  • Modify: frontend/src/components/team/TeamSessionPanel.vue:110-120

Step 1: 在 TeamSessionPanel 中添加语音按钮

end-game-btn 按钮之后、dissolve-btn 之前(约第 116 行区域),添加语音房间入口:

<script setup> 中导入 useRoute

import { useRoute } from 'vue-router'
const route = useRoute()

在 template 中,start-game-btn 按钮之后、end-game-btn 之前添加:

    <router-link
      v-if="session.status === 'recruiting' || session.status === 'playing'"
      :to="`/group/${route.params.id}/voice/${session.id}`"
      class="voice-btn"
    >
      语音房间
    </router-link>

添加对应样式:

.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);
}

Step 2: Commit

git add frontend/src/components/team/TeamSessionPanel.vue
git commit -m "feat(voice): add voice room entry button to TeamSessionPanel"

Task 9: PocketBase hook — 签发 LiveKit token

Files:

  • Modify: backend/pb_hooks/main.js
  • Create: backend/pb_hooks/package.json

注意: PocketBase 0.22.4 muchobien 镜像可能不支持 JS hooks(参见现有 main.js 的注释)。如果 hook 不工作,前端 voice.ts 已经有 fallback 错误提示。此 Task 为可选增强。

Step 1: 创建 package.json

{
  "name": "gamegroup-pb-hooks",
  "private": true,
  "dependencies": {
    "livekit-server-sdk": "^2.0"
  }
}

Run: cd backend/pb_hooks && npm install

Step 2: 更新 main.js — 添加 token 签发路由

在现有注释之后添加:

// LiveKit voice token endpoint
routerAdd("POST", "/api/voice-token/{sessionId}", (c) => {
  const sessionId = c.pathParam("sessionId")
  const user = c.authRecord
  if (!user) {
    throw new BadRequestError("未登录")
  }

  // 验证用户是 session 成员
  const session = $app.dao().findRecordById("team_sessions", sessionId)
  const members = session.getStringSlice("members")
  if (!members.includes(user.id)) {
    throw new ForbiddenError("你不是该小队的成员")
  }

  // LiveKit token 签发
  // 注意:需要 livekit-server-sdk,如果 PocketBase JS VM 不支持 npm 模块,
  // 可以改用外部 API 或 PocketBase Go middleware
  const { AccessToken } = require("livekit-server-sdk")

  const apiKey = "APIyxZGQjM2"
  const apiSecret = "secretNmU4ZDU3YjA0OWIxNDM4YjhlNWY3YTFjZGUzOWRi"

  const at = new AccessToken(apiKey, apiSecret, {
    identity: user.id,
    name: user.getString("name") || user.getString("username"),
  })

  at.addGrant({
    roomJoin: true,
    room: `team-${sessionId}`,
    canPublish: true,
    canSubscribe: true,
  })

  const token = at.toJwt()

  return c.json(200, { token: token })
}, $apis.requireAuth())

Step 3: Commit

git add backend/pb_hooks/main.js backend/pb_hooks/package.json backend/pb_hooks/package-lock.json
git commit -m "feat(voice): add PocketBase hook for LiveKit token issuance"

Task 10: 构建验证 + 部署测试

Files: 无新文件

Step 1: 前端构建

Run: cd frontend && npm run build Expected: 构建成功,无 TypeScript 错误

Step 2: 部署 Dev 环境

Run: ./deploy-dev.sh Expected: 前端容器构建并启动在 7033 端口

Step 3: 功能验证

打开 http://192.168.1.14:7033

  1. 登录 → 进入群组 → 创建/查看临时小组
  2. 组队卡片中应显示"语音房间"按钮
  3. 点击进入语音房间页面(此时因 LiveKit 容器未启动会显示错误,属正常)
  4. 启动 LiveKit 容器后重试验证完整流程

Step 4: Commit

git commit --allow-empty -m "feat(voice): voice room feature complete - v0.4.0"