Files
nl-im-uniapp/src/components/call/GroupCallWindow.vue
2025-12-11 14:46:00 +08:00

503 lines
13 KiB
Vue

<template>
<view
v-if="callState.joined && !callState.incoming"
class="call-window"
:class="{ minimized: callState.minimized }"
>
<!-- 最小化内容 -->
<view v-if="callState.minimized" class="minimized-content" @click="toggleMinimize">
<svg viewBox="0 0 24 24" fill="none" stroke="#10b981" stroke-width="2">
<polygon points="23 7 16 12 23 17 23 7"/>
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"/>
</svg>
<text class="mini-duration">{{ formatDuration(callState.duration) }}</text>
<view class="mini-pulse"></view>
</view>
<!-- 顶部功能栏 (渐变遮罩) -->
<view v-if="!callState.minimized" class="window-header">
<view class="header-left">
<view class="status-dot"></view>
<text class="header-title">群聊通话</text>
<text class="duration">{{ formatDuration(callState.duration) }}</text>
</view>
<view class="minimize-btn" @click="toggleMinimize">
<!-- minimize-2 icon -->
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
<polyline points="4 14 10 14 10 20"/>
<polyline points="20 10 14 10 14 4"/>
<line x1="14" y1="10" x2="21" y2="3"/>
<line x1="3" y1="21" x2="10" y2="14"/>
</svg>
</view>
</view>
<!-- 视频网格 -->
<scroll-view v-if="!callState.minimized" class="video-grid" scroll-y>
<!-- 自己的画面 -->
<view class="video-item self-video">
<!-- #ifdef H5 -->
<video
v-if="localStream && !callState.isSelfCamOff"
ref="localVideoRef"
class="video-element mirror"
autoplay
muted
playsinline
:srcObject="localStream"
></video>
<!-- #endif -->
<view v-if="callState.isSelfCamOff" class="cam-off-placeholder">
<app-avatar :name="'我'" :size="160" round />
<text class="cam-off-text">摄像头已关闭</text>
</view>
<view class="video-label">
<text class="label-name">我</text>
<svg v-if="callState.isSelfMuted" class="mute-icon" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2">
<line x1="1" y1="1" x2="23" y2="23"/>
<path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"/>
</svg>
</view>
</view>
<!-- 其他成员 -->
<view
v-for="user in participants"
:key="user.userId"
class="video-item"
>
<!-- #ifdef H5 -->
<video
v-if="user.stream && !user.isCamOff"
class="video-element"
autoplay
playsinline
:srcObject="user.stream"
></video>
<!-- #endif -->
<view v-if="!user.stream || user.isCamOff" class="cam-off-placeholder">
<view class="avatar-wrap">
<app-avatar :src="user.avatar" :name="user.name" :size="160" round />
<view v-if="user.status === 'connecting'" class="connecting-overlay">
<wd-loading color="#fff" size="32rpx" />
</view>
</view>
<text class="participant-name">{{ user.name }}</text>
<text class="participant-status">
{{ user.status === 'connecting' ? '连接中...' : user.status === 'rejected' ? '已拒绝' : '摄像头关闭' }}
</text>
</view>
<view class="video-label">
<text class="label-name">{{ user.name }}</text>
<svg v-if="user.isMuted" class="mute-icon" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2">
<line x1="1" y1="1" x2="23" y2="23"/>
<path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"/>
</svg>
</view>
<!-- 说话指示 -->
<view
v-if="!user.isMuted && user.volume && user.volume > 0.05"
class="speaking-indicator"
></view>
</view>
</scroll-view>
<!-- 底部控制栏 -->
<view v-if="!callState.minimized" class="control-bar">
<view class="control-item" @click="webRTC.toggleSelfMute()">
<view class="control-btn" :class="{ active: callState.isSelfMuted }">
<svg v-if="callState.isSelfMuted" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="1" y1="1" x2="23" y2="23"/>
<path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"/>
<path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"/>
<line x1="12" y1="19" x2="12" y2="23"/>
<line x1="8" y1="23" x2="16" y2="23"/>
</svg>
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
<line x1="12" y1="19" x2="12" y2="23"/>
<line x1="8" y1="23" x2="16" y2="23"/>
</svg>
</view>
<text class="control-label">{{ callState.isSelfMuted ? '已静音' : '静音' }}</text>
</view>
<view class="control-item" @click="webRTC.leaveCall()">
<view class="control-btn hangup-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.5">
<path d="M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7 2 2 0 0 1 1.72 2v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.42 19.42 0 0 1-3.33-2.67m-2.67-3.34a19.79 19.79 0 0 1-3.07-8.63A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91"/>
<line x1="1" y1="1" x2="23" y2="23"/>
</svg>
</view>
<text class="control-label">挂断</text>
</view>
<view class="control-item" @click="webRTC.toggleSelfCamera()">
<view class="control-btn" :class="{ active: callState.isSelfCamOff }">
<svg v-if="callState.isSelfCamOff" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M16 16v1a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2m5.66 0H14a2 2 0 0 1 2 2v3.34l1 1L23 7v10"/>
<line x1="1" y1="1" x2="23" y2="23"/>
</svg>
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="23 7 16 12 23 17 23 7"/>
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"/>
</svg>
</view>
<text class="control-label">{{ callState.isSelfCamOff ? '已关闭' : '摄像头' }}</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, watchEffect } from 'vue'
import { useGroupWebRTC } from '@/composables/useGroupWebRTC'
import AppAvatar from '@/components/common/AppAvatar.vue'
const webRTC = useGroupWebRTC()
const { callState, participants, formatDuration, localStream } = webRTC
const localVideoRef = ref<HTMLVideoElement | null>(null)
watchEffect(() => {
const stream = localStream.value
const videoEl = localVideoRef.value
if (videoEl && stream) {
videoEl.muted = true
if ((videoEl as any).srcObject !== stream) {
(videoEl as any).srcObject = stream
videoEl.play().catch(e => console.error(e))
}
}
})
function toggleMinimize() {
callState.minimized = !callState.minimized
}
</script>
<style lang="scss" scoped>
.call-window {
position: fixed;
z-index: 9999;
background: #1c1c1e;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.5);
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
&:not(.minimized) {
inset: 0;
}
&.minimized {
top: calc(var(--status-bar-height, 0) + 240rpx);
right: 32rpx;
width: 200rpx;
height: 280rpx;
border-radius: 32rpx;
border: 2rpx solid rgba(255, 255, 255, 0.15);
}
}
.minimized-content {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16rpx;
background: #2c2c2e;
position: relative;
cursor: pointer;
svg {
width: 64rpx;
height: 64rpx;
}
.mini-duration {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.9);
font-family: monospace;
}
.mini-pulse {
position: absolute;
bottom: 16rpx;
width: 8rpx;
height: 8rpx;
background: #10b981;
border-radius: 50%;
animation: pulse 2s infinite;
}
&:active {
opacity: 0.9;
}
}
.window-header {
height: 200rpx;
padding: 0 40rpx;
padding-top: calc(24rpx + var(--status-bar-height, 0));
display: flex;
align-items: center;
justify-content: space-between;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.6) 0%, transparent 100%);
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 50;
pointer-events: none;
}
.header-left {
display: flex;
align-items: center;
gap: 24rpx;
pointer-events: auto;
}
.status-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
background: #10b981;
box-shadow: 0 0 20rpx #10b981;
animation: pulse 2s infinite;
}
.header-title {
font-size: 28rpx;
font-weight: 500;
color: #fff;
letter-spacing: 0.5rpx;
}
.duration {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.5);
font-family: monospace;
padding-left: 24rpx;
border-left: 2rpx solid rgba(255, 255, 255, 0.1);
}
.minimize-btn {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
pointer-events: auto;
svg {
width: 32rpx;
height: 32rpx;
}
&:active {
transform: scale(0.9);
background: rgba(255, 255, 255, 0.2);
}
}
.video-grid {
flex: 1;
padding: calc(var(--status-bar-height, 0) + 200rpx) 24rpx 240rpx;
display: flex;
flex-wrap: wrap;
gap: 16rpx;
align-content: flex-start;
overflow-y: auto;
}
.video-item {
position: relative;
width: calc(50% - 8rpx);
aspect-ratio: 3 / 4;
background: #2c2c2e;
border-radius: 32rpx;
overflow: hidden;
border: 2rpx solid rgba(255, 255, 255, 0.05);
}
.video-element {
width: 100%;
height: 100%;
object-fit: cover;
&.mirror { transform: scaleX(-1); }
}
.cam-off-placeholder {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16rpx;
background: #2c2c2e;
}
.avatar-wrap {
position: relative;
}
.connecting-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.5);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.cam-off-text, .participant-name {
font-size: 26rpx;
color: #fff;
font-weight: 500;
}
.participant-status {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.5);
}
.video-label {
position: absolute;
bottom: 24rpx;
left: 24rpx;
display: flex;
align-items: center;
gap: 12rpx;
padding: 8rpx 16rpx;
background: rgba(0, 0, 0, 0.4);
border-radius: 16rpx;
backdrop-filter: blur(16rpx);
border: 1rpx solid rgba(255, 255, 255, 0.1);
.label-name {
font-size: 20rpx;
color: #fff;
font-weight: 500;
max-width: 140rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mute-icon {
width: 24rpx;
height: 24rpx;
}
}
.speaking-indicator {
position: absolute;
inset: 0;
border: 4rpx solid #10b981;
border-radius: 32rpx;
pointer-events: none;
animation: speakPulse 0.5s ease-in-out;
}
.control-bar {
height: 320rpx;
padding: 40rpx 0;
padding-bottom: calc(64rpx + env(safe-area-inset-bottom));
display: flex;
align-items: center;
justify-content: center;
gap: 80rpx;
background: linear-gradient(0deg, rgba(0, 0, 0, 0.9) 0%, rgba(0, 0, 0, 0.5) 50%, transparent 100%);
position: absolute;
bottom: 0;
left: 0;
right: 0;
z-index: 50;
}
.control-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
}
.control-btn {
width: 112rpx;
height: 112rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
border: 2rpx solid rgba(255, 255, 255, 0.05);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
transition: all 0.3s;
backdrop-filter: blur(8rpx);
svg {
width: 48rpx;
height: 48rpx;
}
&:active { transform: scale(0.95); }
&.active {
background: #fff;
color: #000;
border-color: #fff;
}
}
.hangup-btn {
width: 144rpx;
height: 72rpx;
border-radius: 56rpx;
background: #ef4444;
border: none;
box-shadow: 0 8rpx 32rpx rgba(239, 68, 68, 0.3);
padding: 0 32rpx;
svg {
width: 64rpx;
height: 64rpx;
}
&:active { background: #dc2626; }
}
.control-label {
font-size: 20rpx;
color: rgba(255, 255, 255, 0.5);
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@keyframes speakPulse {
0% { opacity: 0; }
50% { opacity: 1; }
100% { opacity: 0; }
}
</style>