适配微信小程序
This commit is contained in:
@@ -4,10 +4,14 @@
|
||||
<!-- 最小化悬浮窗 -->
|
||||
<view v-if="call.minimized" class="minimized-bar" @click="$emit('toggleMinimize')">
|
||||
<view class="pulse-dot"></view>
|
||||
<svg class="mini-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path v-if="call.type === 'video'" d="M23 7l-7 5 7 5V7z M16 5H3a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h13a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2z"/>
|
||||
<path v-else d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 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.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>
|
||||
</svg>
|
||||
<!-- 替换 SVG 为 wd-icon -->
|
||||
<view class="mini-icon-wrap">
|
||||
<wd-icon
|
||||
:name="call.type === 'video' ? 'video' : 'phone'"
|
||||
size="48rpx"
|
||||
color="#10b981"
|
||||
/>
|
||||
</view>
|
||||
<text class="mini-duration">{{ formatDuration(call.duration) }}</text>
|
||||
</view>
|
||||
|
||||
@@ -21,24 +25,19 @@
|
||||
|
||||
<!-- 最小化按钮 (右上角) -->
|
||||
<view class="minimize-btn" @click="$emit('toggleMinimize')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" 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>
|
||||
<wd-icon name="arrow-down" size="48rpx" color="#fff" />
|
||||
</view>
|
||||
|
||||
<!-- 视频区域 -->
|
||||
<view v-if="call.type === 'video'" class="video-area">
|
||||
<!-- 远端视频 - 使用原生 video 元素以支持 srcObject -->
|
||||
<!-- 远端视频 -->
|
||||
<video
|
||||
v-if="remoteStream"
|
||||
ref="remoteVideoRef"
|
||||
class="remote-video"
|
||||
autoplay
|
||||
playsinline
|
||||
:srcObject="remoteStream"
|
||||
v-if="remoteStream"
|
||||
ref="remoteVideoRef"
|
||||
class="remote-video"
|
||||
autoplay
|
||||
playsinline
|
||||
:srcObject="remoteStream"
|
||||
></video>
|
||||
<view v-else class="video-placeholder">
|
||||
<view class="avatar-section">
|
||||
@@ -49,15 +48,15 @@
|
||||
<text class="caller-name">{{ call.callerName || '对方' }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 本地视频 - 使用原生 video 元素以支持 srcObject -->
|
||||
<!-- 本地视频 -->
|
||||
<video
|
||||
v-if="localStream && !call.camOff"
|
||||
ref="localVideoRef"
|
||||
class="local-video"
|
||||
autoplay
|
||||
muted
|
||||
playsinline
|
||||
:srcObject="localStream"
|
||||
v-if="localStream && !call.camOff"
|
||||
ref="localVideoRef"
|
||||
class="local-video"
|
||||
autoplay
|
||||
muted
|
||||
playsinline
|
||||
:srcObject="localStream"
|
||||
></video>
|
||||
</view>
|
||||
|
||||
@@ -80,17 +79,13 @@
|
||||
<template v-if="call.status === 'incoming'">
|
||||
<view class="action-item" @click="$emit('reject')">
|
||||
<view class="control-btn decline">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
|
||||
<path d="M23 7l-7 5 7 5V7z M16 5H3a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h13a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2z"/>
|
||||
</svg>
|
||||
<wd-icon name="close-bold" size="56rpx" color="#fff" />
|
||||
</view>
|
||||
<text class="action-label">拒绝</text>
|
||||
</view>
|
||||
<view class="action-item" @click="$emit('accept')">
|
||||
<view class="control-btn accept">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
|
||||
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 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.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>
|
||||
</svg>
|
||||
<wd-icon name="phone-filled" size="64rpx" color="#fff" />
|
||||
</view>
|
||||
<text class="action-label">接听</text>
|
||||
</view>
|
||||
@@ -98,42 +93,34 @@
|
||||
|
||||
<!-- 通话中状态 -->
|
||||
<template v-else-if="call.status === 'connected'">
|
||||
<!-- 静音 -->
|
||||
<view class="action-item" @click="$emit('toggleMute')">
|
||||
<view class="control-btn" :class="{ active: call.muted }">
|
||||
<svg v-if="call.muted" 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>
|
||||
<wd-icon
|
||||
:name="call.muted ? 'mic-off' : 'mic-on'"
|
||||
size="56rpx"
|
||||
:color="call.muted ? '#000' : '#fff'"
|
||||
/>
|
||||
</view>
|
||||
<text class="action-label">{{ call.muted ? '已静音' : '静音' }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 摄像头 (仅视频通话) -->
|
||||
<view v-if="call.type === 'video'" class="action-item" @click="$emit('toggleCamera')">
|
||||
<view class="control-btn" :class="{ active: call.camOff }">
|
||||
<svg v-if="call.camOff" 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>
|
||||
<wd-icon
|
||||
:name="call.camOff ? 'video-off' : 'video'"
|
||||
size="56rpx"
|
||||
:color="call.camOff ? '#000' : '#fff'"
|
||||
/>
|
||||
</view>
|
||||
<text class="action-label">{{ call.camOff ? '已关闭' : '摄像头' }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 挂断 -->
|
||||
<view class="action-item" @click="$emit('end')">
|
||||
<view class="control-btn hangup">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
|
||||
<path d="M23 7l-7 5 7 5V7z M16 5H3a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h13a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2z"/>
|
||||
</svg>
|
||||
<wd-icon name="phone-off-filled" size="64rpx" color="#fff" />
|
||||
</view>
|
||||
<text class="action-label">挂断</text>
|
||||
</view>
|
||||
@@ -143,9 +130,7 @@
|
||||
<template v-else-if="call.status === 'outgoing'">
|
||||
<view class="action-item" @click="$emit('end')">
|
||||
<view class="control-btn hangup">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2">
|
||||
<path d="M23 7l-7 5 7 5V7z M16 5H3a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h13a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2z"/>
|
||||
</svg>
|
||||
<wd-icon name="phone-off-filled" size="64rpx" color="#fff" />
|
||||
</view>
|
||||
<text class="action-label">挂断</text>
|
||||
</view>
|
||||
@@ -159,16 +144,12 @@
|
||||
<view v-if="call.active" class="call-unsupported">
|
||||
<view class="unsupported-content">
|
||||
<view class="icon-box">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M23 7l-7 5 7 5V7z"/>
|
||||
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"/>
|
||||
<line x1="1" y1="1" x2="23" y2="23"/>
|
||||
</svg>
|
||||
<wd-icon name="video-off" size="80rpx" color="#f97316" />
|
||||
</view>
|
||||
<text class="unsupported-title">暂不支持音视频通话</text>
|
||||
<text class="unsupported-desc">当前平台暂不支持音视频通话功能</text>
|
||||
<text class="unsupported-desc">请使用网页版体验完整功能</text>
|
||||
|
||||
|
||||
<view class="unsupported-actions">
|
||||
<wd-button type="info" plain @click="copyWebUrl" custom-style="margin-right: 20rpx;">
|
||||
复制网页链接
|
||||
@@ -212,9 +193,7 @@ const localVideoRef = ref<HTMLVideoElement | null>(null)
|
||||
const remoteVideoRef = ref<HTMLVideoElement | null>(null)
|
||||
const toast = useToast()
|
||||
|
||||
// 复制网页链接(用于非H5平台引导用户)
|
||||
function copyWebUrl() {
|
||||
// TODO: 替换为实际的网页版地址
|
||||
const webUrl = 'https://your-web-domain.com/chat'
|
||||
uni.setClipboardData({
|
||||
data: webUrl,
|
||||
@@ -260,7 +239,7 @@ watch(() => props.remoteStream, (stream) => {
|
||||
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
|
||||
&.minimized {
|
||||
top: calc(var(--status-bar-height, 0) + 240rpx);
|
||||
top: calc(var(--status-bar-height, 0) + var(--mp-safe-top, 0px) + 240rpx);
|
||||
right: 32rpx;
|
||||
left: auto;
|
||||
bottom: auto;
|
||||
@@ -294,10 +273,12 @@ watch(() => props.remoteStream, (stream) => {
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.mini-icon {
|
||||
.mini-icon-wrap {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
color: #10b981;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.mini-duration {
|
||||
@@ -318,7 +299,7 @@ watch(() => props.remoteStream, (stream) => {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
background: #0f172a; // 设计稿: 来电背景
|
||||
background: #0f172a;
|
||||
|
||||
.blur-img {
|
||||
width: 100%;
|
||||
@@ -332,12 +313,11 @@ watch(() => props.remoteStream, (stream) => {
|
||||
.blur-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.8); // 设计稿: bg-[#0f172a]/80
|
||||
background: rgba(15, 23, 42, 0.8);
|
||||
backdrop-filter: blur(48px);
|
||||
-webkit-backdrop-filter: blur(48px);
|
||||
}
|
||||
|
||||
// 设计稿: 呼吸光球动画
|
||||
|
||||
&::before, &::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
@@ -348,25 +328,25 @@ watch(() => props.remoteStream, (stream) => {
|
||||
opacity: 0.4;
|
||||
animation: glow-breathe 8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
|
||||
&::before {
|
||||
top: 10%;
|
||||
left: -100rpx;
|
||||
background: #9333ea; // purple-600
|
||||
background: #9333ea;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
|
||||
&::after {
|
||||
bottom: 10%;
|
||||
right: -100rpx;
|
||||
background: #2563eb; // blue-600
|
||||
background: #2563eb;
|
||||
animation-delay: 4s;
|
||||
}
|
||||
}
|
||||
|
||||
.minimize-btn {
|
||||
position: absolute;
|
||||
top: calc(28rpx + var(--status-bar-height, 0));
|
||||
top: calc(28rpx + var(--status-bar-height, 0) + var(--mp-safe-top, 0px));
|
||||
right: 40rpx;
|
||||
z-index: 20;
|
||||
width: 72rpx;
|
||||
@@ -379,12 +359,6 @@ watch(() => props.remoteStream, (stream) => {
|
||||
justify-content: center;
|
||||
transition: all 0.15s;
|
||||
|
||||
svg {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.9);
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
@@ -432,17 +406,16 @@ watch(() => props.remoteStream, (stream) => {
|
||||
position: relative;
|
||||
margin-bottom: 48rpx;
|
||||
|
||||
// 设计稿: 头像波纹效果
|
||||
.avatar-ring {
|
||||
position: absolute;
|
||||
inset: -20rpx;
|
||||
border-radius: 50%;
|
||||
border: 2rpx solid rgba(255, 255, 255, 0.3); // 设计稿: border-white/30
|
||||
border: 2rpx solid rgba(255, 255, 255, 0.3);
|
||||
|
||||
&.ring-1 {
|
||||
animation: ripple 2s cubic-bezier(0, 0, 0.2, 1) infinite;
|
||||
}
|
||||
|
||||
|
||||
&.ring-2 {
|
||||
animation: ripple 2s cubic-bezier(0, 0, 0.2, 1) infinite;
|
||||
animation-delay: 1s;
|
||||
@@ -518,11 +491,6 @@ watch(() => props.remoteStream, (stream) => {
|
||||
color: #fff;
|
||||
transition: all 0.3s;
|
||||
|
||||
svg {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
}
|
||||
|
||||
&:active { transform: scale(0.95); }
|
||||
|
||||
&.active {
|
||||
@@ -537,19 +505,12 @@ watch(() => props.remoteStream, (stream) => {
|
||||
background: #10b981;
|
||||
border: none;
|
||||
box-shadow: 0 0 80rpx rgba(16, 185, 129, 0.4);
|
||||
|
||||
svg {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
}
|
||||
|
||||
&:active { background: #059669; }
|
||||
}
|
||||
|
||||
&.decline {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
|
||||
&:active { background: #ef4444; }
|
||||
}
|
||||
|
||||
@@ -560,12 +521,6 @@ watch(() => props.remoteStream, (stream) => {
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 8rpx 40rpx rgba(239, 68, 68, 0.3);
|
||||
|
||||
svg {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
}
|
||||
|
||||
&:active { background: #dc2626; }
|
||||
}
|
||||
}
|
||||
@@ -603,12 +558,6 @@ watch(() => props.remoteStream, (stream) => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 48rpx;
|
||||
|
||||
svg {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
color: #f97316;
|
||||
}
|
||||
}
|
||||
|
||||
.unsupported-title {
|
||||
@@ -648,13 +597,11 @@ watch(() => props.remoteStream, (stream) => {
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
// 设计稿: 呼吸光球动画
|
||||
@keyframes glow-breathe {
|
||||
0%, 100% { transform: scale(1); opacity: 0.3; }
|
||||
50% { transform: scale(1.2); opacity: 0.5; }
|
||||
}
|
||||
|
||||
// 设计稿: 头像波纹动画
|
||||
@keyframes ripple {
|
||||
0% { transform: scale(1); opacity: 0.4; }
|
||||
100% { transform: scale(2.5); opacity: 0; }
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
<template>
|
||||
<view
|
||||
v-if="showBanner"
|
||||
class="group-call-banner"
|
||||
:class="{ dark: isDark }"
|
||||
v-if="showBanner"
|
||||
class="group-call-banner"
|
||||
:class="{ dark: isDark }"
|
||||
>
|
||||
<view class="banner-left">
|
||||
<!-- 动态图标 -->
|
||||
<view class="call-icon">
|
||||
<view class="pulse-ring"></view>
|
||||
<view class="icon-inner">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" 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>
|
||||
<!-- 使用 wot-ui icon 替代 SVG/Image -->
|
||||
<wd-icon name="video" size="40rpx" color="#fff" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -27,21 +25,21 @@
|
||||
<!-- 头像堆叠 -->
|
||||
<view class="avatar-stack">
|
||||
<view
|
||||
v-for="(user, index) in previewParticipants"
|
||||
:key="user.userId"
|
||||
class="stacked-avatar"
|
||||
:style="{ marginLeft: index > 0 ? '-24rpx' : '0', zIndex: 10 - index }"
|
||||
v-for="(user, index) in previewParticipants"
|
||||
:key="user.userId"
|
||||
class="stacked-avatar"
|
||||
:style="{ marginLeft: index > 0 ? '-24rpx' : '0', zIndex: 10 - index }"
|
||||
>
|
||||
<app-avatar
|
||||
:src="user.avatar"
|
||||
:name="user.name"
|
||||
:size="40"
|
||||
round
|
||||
:src="user.avatar"
|
||||
:name="user.name"
|
||||
:size="40"
|
||||
round
|
||||
/>
|
||||
</view>
|
||||
<view
|
||||
v-if="participantsCount > 5"
|
||||
class="more-count"
|
||||
v-if="participantsCount > 5"
|
||||
class="more-count"
|
||||
>
|
||||
+{{ participantsCount - 5 }}
|
||||
</view>
|
||||
@@ -71,9 +69,9 @@ const { callState, participants } = groupWebRTC
|
||||
|
||||
const showBanner = computed(() => {
|
||||
return callState.groupId === props.roomId &&
|
||||
!callState.joined &&
|
||||
!callState.incoming &&
|
||||
callState.roomId !== ''
|
||||
!callState.joined &&
|
||||
!callState.incoming &&
|
||||
callState.roomId !== ''
|
||||
})
|
||||
|
||||
const participantsCount = computed(() => participants.value.length)
|
||||
@@ -154,13 +152,6 @@ function handleJoin() {
|
||||
inset: 0;
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.2) 0%, transparent 100%);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,502 +1,219 @@
|
||||
<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 v-if="showBanner" class="banner-wrapper" :class="{ 'banner-hide': !showBanner }">
|
||||
<view class="glass-capsule" @click="handleJoin">
|
||||
<view class="left-section">
|
||||
<view class="icon-container">
|
||||
<view class="pulse-ring"></view>
|
||||
<view class="icon-box">
|
||||
<svg class="icon-svg" 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>
|
||||
</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 class="info-box">
|
||||
<view class="title-row">
|
||||
<text class="title">通话进行中</text>
|
||||
<view class="count-badge">
|
||||
<text>{{ participantsCount }}人</text>
|
||||
</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 class="avatar-stack">
|
||||
<view
|
||||
v-for="(user, index) in previewParticipants"
|
||||
:key="user.userId"
|
||||
class="stack-item"
|
||||
:style="{ zIndex: 10 - index, marginLeft: index === 0 ? '0' : '-16rpx' }"
|
||||
>
|
||||
<app-avatar :src="user.avatar" :name="user.name" :size="32" round />
|
||||
</view>
|
||||
<view v-if="participantsCount > 3" class="more-badge" :style="{ zIndex: 0 }">
|
||||
<text>+{{ participantsCount - 3 }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</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 class="join-btn">
|
||||
<text>加入</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watchEffect } from 'vue'
|
||||
// ... 保持原有 script 逻辑不变 ...
|
||||
import { computed } from 'vue'
|
||||
import { useGroupWebRTC } from '@/composables/useGroupWebRTC'
|
||||
import AppAvatar from '@/components/common/AppAvatar.vue'
|
||||
|
||||
const webRTC = useGroupWebRTC()
|
||||
const { callState, participants, formatDuration, localStream } = webRTC
|
||||
const props = defineProps<{ roomId: string }>()
|
||||
const groupWebRTC = useGroupWebRTC()
|
||||
const { callState, participants } = groupWebRTC
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
const showBanner = computed(() => {
|
||||
return callState.groupId === props.roomId && !callState.joined && !callState.incoming && callState.roomId !== ''
|
||||
})
|
||||
|
||||
function toggleMinimize() {
|
||||
callState.minimized = !callState.minimized
|
||||
}
|
||||
const participantsCount = computed(() => participants.value.length)
|
||||
const previewParticipants = computed(() => participants.value.slice(0, 3)) // 只显示前3个
|
||||
function handleJoin() { groupWebRTC.joinCurrentCall() }
|
||||
</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);
|
||||
}
|
||||
.banner-wrapper {
|
||||
padding: 24rpx 32rpx;
|
||||
animation: slideUp 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.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));
|
||||
.glass-capsule {
|
||||
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;
|
||||
padding: 24rpx;
|
||||
background: #fff;
|
||||
border-radius: 32rpx;
|
||||
box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.06), 0 0 0 1rpx rgba(0,0,0,0.05);
|
||||
transition: all 0.2s;
|
||||
|
||||
.dark & {
|
||||
background: #292524;
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.header-left {
|
||||
.left-section {
|
||||
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);
|
||||
.icon-container {
|
||||
position: relative;
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s;
|
||||
pointer-events: auto;
|
||||
|
||||
svg {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
.pulse-ring {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
border-radius: 50%;
|
||||
animation: pulse-ring 2s infinite;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.9);
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
.icon-box {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #10b981;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 8rpx 16rpx rgba(16, 185, 129, 0.3);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.icon-svg {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
.info-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16rpx;
|
||||
background: #2c2c2e;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.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;
|
||||
.title-row {
|
||||
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;
|
||||
.title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
.dark & { color: #f5f5f4; }
|
||||
}
|
||||
|
||||
.count-badge {
|
||||
padding: 2rpx 10rpx;
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
border: 1rpx solid rgba(16, 185, 129, 0.2);
|
||||
border-radius: 8rpx;
|
||||
|
||||
text {
|
||||
font-size: 20rpx;
|
||||
font-weight: 600;
|
||||
color: #10b981;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.avatar-stack {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: 8rpx;
|
||||
|
||||
.stack-item {
|
||||
position: relative;
|
||||
border: 4rpx solid #fff;
|
||||
border-radius: 50%;
|
||||
.dark & { border-color: #292524; }
|
||||
}
|
||||
|
||||
.more-badge {
|
||||
margin-left: -16rpx;
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
background: #f3f4f6;
|
||||
border: 4rpx solid #fff;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.dark & {
|
||||
background: #44403c;
|
||||
border-color: #292524;
|
||||
}
|
||||
|
||||
text {
|
||||
font-size: 16rpx;
|
||||
font-weight: 700;
|
||||
color: #6b7280;
|
||||
.dark & { color: #d6d3d1; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.join-btn {
|
||||
background: #10b981;
|
||||
padding: 16rpx 32rpx;
|
||||
border-radius: 24rpx;
|
||||
box-shadow: 0 8rpx 20rpx rgba(16, 185, 129, 0.25);
|
||||
|
||||
text {
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
max-width: 140rpx;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mute-icon {
|
||||
width: 24rpx;
|
||||
height: 24rpx;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.speaking-indicator {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 4rpx solid #10b981;
|
||||
border-radius: 32rpx;
|
||||
pointer-events: none;
|
||||
animation: speakPulse 0.5s ease-in-out;
|
||||
@keyframes pulse-ring {
|
||||
0% { transform: scale(0.8); opacity: 0.5; }
|
||||
100% { transform: scale(1.4); opacity: 0; }
|
||||
}
|
||||
|
||||
.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; }
|
||||
@keyframes slideUp {
|
||||
from { transform: translateY(20rpx); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,139 +1,130 @@
|
||||
<!--
|
||||
小程序音视频通话窗口组件
|
||||
使用 live-pusher/live-player 实现
|
||||
适用平台:微信小程序
|
||||
小程序音视频通话窗口组件 - 同层渲染 + wot-ui Icon 版
|
||||
适用平台:微信小程序 (需基础库 2.9.1+)
|
||||
-->
|
||||
<template>
|
||||
<!-- #ifdef MP-WEIXIN -->
|
||||
<view v-if="call.active" class="call-window" :class="{ minimized: call.minimized }">
|
||||
<!-- 最小化状态 -->
|
||||
<view v-if="call.minimized" class="minimized-view" @click="toggleMinimize">
|
||||
<view class="mini-avatar">
|
||||
<image v-if="call.callerAvatar" :src="call.callerAvatar" mode="aspectFill" />
|
||||
<text v-else class="avatar-text">{{ call.callerName?.charAt(0) || '?' }}</text>
|
||||
</view>
|
||||
<view class="mini-info">
|
||||
<text class="mini-duration">{{ formatDuration(call.duration) }}</text>
|
||||
<text class="mini-status">{{ call.statusText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="call.active" class="mp-call-container">
|
||||
|
||||
<!-- 全屏状态 -->
|
||||
<view v-else class="fullscreen-view">
|
||||
<!-- 视频区域 -->
|
||||
<view class="video-container">
|
||||
<!-- 远端视频 -->
|
||||
<view class="remote-videos">
|
||||
<live-player
|
||||
<!-- 1. 视频层 -->
|
||||
<view class="video-layer">
|
||||
<!-- 远端视频流 -->
|
||||
<view class="remote-grid" :class="gridClass">
|
||||
<live-player
|
||||
v-for="stream in remoteStreams"
|
||||
:key="stream.userId"
|
||||
class="remote-video"
|
||||
class="live-player-item"
|
||||
:src="stream.pullUrl"
|
||||
mode="RTC"
|
||||
autoplay
|
||||
muted="{{false}}"
|
||||
:object-fit="'contain'"
|
||||
@statechange="(e: any) => onPlayerStateChange(e, stream.userId)"
|
||||
>
|
||||
<view class="video-overlay">
|
||||
<text class="user-name">{{ stream.userName || '对方' }}</text>
|
||||
</view>
|
||||
</live-player>
|
||||
|
||||
<!-- 无远端视频时的占位 -->
|
||||
<view v-if="remoteStreams.length === 0" class="remote-placeholder">
|
||||
<view class="caller-avatar-large">
|
||||
<image v-if="call.callerAvatar" :src="call.callerAvatar" mode="aspectFill" />
|
||||
<text v-else class="avatar-text">{{ call.callerName?.charAt(0) || '?' }}</text>
|
||||
</view>
|
||||
<text class="caller-name">{{ call.callerName || '未知' }}</text>
|
||||
<text class="call-status">{{ call.statusText }}</text>
|
||||
:muted="false"
|
||||
object-fit="fillCrop"
|
||||
@statechange="(e) => onPlayerStateChange(e, stream.userId)"
|
||||
@error="(e) => onPlayerError(e, stream.userId)"
|
||||
>
|
||||
<!-- 名字标签 (同层渲染直接使用 view) -->
|
||||
<view class="player-tag">
|
||||
<text class="tag-text">{{ stream.userName || '连线中...' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</live-player>
|
||||
|
||||
<!-- 本地视频(小窗) -->
|
||||
<view v-if="call.type === 'video' && !call.camOff" class="local-video-container">
|
||||
<live-pusher
|
||||
class="local-video"
|
||||
:url="pushUrl"
|
||||
mode="RTC"
|
||||
autopush
|
||||
:muted="call.muted"
|
||||
:enable-camera="!call.camOff"
|
||||
aspect="9:16"
|
||||
beauty="5"
|
||||
whiteness="3"
|
||||
@statechange="onPusherStateChange"
|
||||
/>
|
||||
<!-- 等待中的占位符 -->
|
||||
<view v-if="remoteStreams.length === 0" class="waiting-placeholder">
|
||||
<image :src="call.callerAvatar || '/static/default-avatar.png'" class="placeholder-avatar" mode="aspectFill" />
|
||||
<text class="placeholder-text">{{ call.statusText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 通话信息 -->
|
||||
<view class="call-info">
|
||||
<text class="duration">{{ formatDuration(call.duration) }}</text>
|
||||
<text class="status-text">{{ call.statusText }}</text>
|
||||
<!-- 本地推流 -->
|
||||
<live-pusher
|
||||
id="local-pusher"
|
||||
class="local-pusher"
|
||||
:url="pushUrl"
|
||||
mode="RTC"
|
||||
:autopush="true"
|
||||
:enable-camera="!call.camOff"
|
||||
:enable-mic="!call.muted"
|
||||
aspect="9:16"
|
||||
beauty="5"
|
||||
whiteness="3"
|
||||
@statechange="onPusherStateChange"
|
||||
@error="onPusherError"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 2. UI 交互层 (标准 View 层级) -->
|
||||
|
||||
<!-- 顶部栏 -->
|
||||
<view class="ui-header">
|
||||
<view class="header-left">
|
||||
<view class="status-dot"></view>
|
||||
<text class="header-title">通话中</text>
|
||||
<text class="header-time">{{ formatDuration(call.duration) }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 控制按钮 -->
|
||||
<view class="controls">
|
||||
<!-- 来电状态 -->
|
||||
<view v-if="call.status === 'incoming'" class="incoming-controls">
|
||||
<view class="control-btn reject" @click="rejectCall">
|
||||
<wd-icon name="close" size="60rpx" color="#fff" />
|
||||
<text>拒绝</text>
|
||||
</view>
|
||||
<view class="control-btn accept" @click="acceptCall">
|
||||
<wd-icon name="phone" size="60rpx" color="#fff" />
|
||||
<text>接听</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 呼叫/通话中状态 -->
|
||||
<view v-else class="call-controls">
|
||||
<!-- 静音 -->
|
||||
<view class="control-btn" :class="{ active: call.muted }" @click="toggleMute">
|
||||
<wd-icon :name="call.muted ? 'mute' : 'sound'" size="48rpx" />
|
||||
<text>{{ call.muted ? '取消静音' : '静音' }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 摄像头 -->
|
||||
<view v-if="call.type === 'video'" class="control-btn" :class="{ active: call.camOff }" @click="toggleCamera">
|
||||
<wd-icon :name="call.camOff ? 'camera-off' : 'camera'" size="48rpx" />
|
||||
<text>{{ call.camOff ? '开启摄像头' : '关闭摄像头' }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 切换摄像头 -->
|
||||
<view v-if="call.type === 'video' && !call.camOff" class="control-btn" @click="switchCamera">
|
||||
<wd-icon name="refresh" size="48rpx" />
|
||||
<text>翻转</text>
|
||||
</view>
|
||||
|
||||
<!-- 挂断 -->
|
||||
<view class="control-btn hangup" @click="endCall">
|
||||
<wd-icon name="phone" size="48rpx" color="#fff" />
|
||||
<text>挂断</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 最小化按钮 -->
|
||||
<view class="minimize-btn" @click="toggleMinimize">
|
||||
<wd-icon name="arrow-down" size="40rpx" />
|
||||
<!-- 最小化图标 -->
|
||||
<wd-icon name="arrow-down-circle" size="60rpx" color="rgba(255,255,255,0.8)" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- #endif -->
|
||||
|
||||
<!-- 非小程序平台提示 -->
|
||||
<!-- #ifndef MP-WEIXIN -->
|
||||
<view v-if="showUnsupportedTip" class="unsupported-tip">
|
||||
<text>当前平台请使用 WebRTC 通话</text>
|
||||
<!-- 底部控制栏 -->
|
||||
<view class="ui-controls">
|
||||
<!-- 呼叫中状态 (接听/拒绝) -->
|
||||
<view v-if="call.status === 'incoming'" class="incoming-actions">
|
||||
<view class="action-btn reject" @click="rejectCall">
|
||||
<view class="btn-circle reject-bg">
|
||||
<wd-icon name="close-bold" size="50rpx" color="#fff" />
|
||||
</view>
|
||||
<text class="action-text">拒绝</text>
|
||||
</view>
|
||||
<view class="action-btn accept" @click="acceptCall">
|
||||
<view class="btn-circle accept-bg">
|
||||
<wd-icon name="phone-filled" size="56rpx" color="#fff" />
|
||||
</view>
|
||||
<text class="action-text">接听</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 通话中状态 -->
|
||||
<view v-else class="active-actions">
|
||||
<!-- 静音 -->
|
||||
<view class="ctrl-btn-wrap" @click="toggleMute">
|
||||
<view class="ctrl-btn" :class="{ active: call.muted }">
|
||||
<wd-icon :name="call.muted ? 'mic-off' : 'mic-on'" size="50rpx" :color="call.muted ? '#000' : '#fff'" />
|
||||
</view>
|
||||
<text class="ctrl-text">静音</text>
|
||||
</view>
|
||||
|
||||
<!-- 挂断 -->
|
||||
<view class="ctrl-btn-wrap" @click="endCall">
|
||||
<view class="ctrl-btn hangup">
|
||||
<wd-icon name="phone-off-filled" size="50rpx" color="#fff" />
|
||||
</view>
|
||||
<text class="ctrl-text">挂断</text>
|
||||
</view>
|
||||
|
||||
<!-- 摄像头 -->
|
||||
<view class="ctrl-btn-wrap" @click="toggleCamera">
|
||||
<view class="ctrl-btn" :class="{ active: call.camOff }">
|
||||
<wd-icon :name="call.camOff ? 'video-off' : 'video'" size="50rpx" :color="call.camOff ? '#000' : '#fff'" />
|
||||
</view>
|
||||
<text class="ctrl-text">摄像头</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 最小化悬浮球 -->
|
||||
<view v-if="call.minimized" class="minimized-ball" @click="toggleMinimize">
|
||||
<image :src="call.callerAvatar || '/static/default-avatar.png'" class="ball-avatar" mode="aspectFill" />
|
||||
<text class="ball-time">{{ formatDuration(call.duration) }}</text>
|
||||
</view>
|
||||
|
||||
</view>
|
||||
<!-- #endif -->
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { computed, watch, nextTick, getCurrentInstance } from 'vue'
|
||||
import { useMiniProgramCall } from '@/composables/useMiniProgramCall'
|
||||
|
||||
const {
|
||||
@@ -145,304 +136,191 @@ const {
|
||||
endCall,
|
||||
toggleMute,
|
||||
toggleCamera,
|
||||
switchCamera,
|
||||
toggleMinimize,
|
||||
formatDuration,
|
||||
onPusherStateChange,
|
||||
onPlayerStateChange,
|
||||
initPusherContext
|
||||
} = useMiniProgramCall()
|
||||
|
||||
const showUnsupportedTip = ref(false)
|
||||
// 获取当前组件实例,用于绑定 Context
|
||||
const instance = getCurrentInstance()
|
||||
|
||||
// 注意:信令监听器在 GlobalCallProvider 中已初始化
|
||||
// 这里不需要再次调用 initListener 和 initPusherContext
|
||||
const gridClass = computed(() => remoteStreams.value.length > 1 ? 'grid-multi' : 'grid-single')
|
||||
|
||||
onMounted(() => {
|
||||
// #ifndef MP-WEIXIN
|
||||
showUnsupportedTip.value = true
|
||||
// #endif
|
||||
// 监听通话激活,初始化 Context
|
||||
watch(() => call.active, (newVal) => {
|
||||
if (newVal) {
|
||||
nextTick(() => {
|
||||
// 传入当前组件实例,确保 live-pusher 能被找到
|
||||
// 在同层渲染模式下,这点尤为重要
|
||||
initPusherContext(instance)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// 清理资源
|
||||
})
|
||||
const onPusherError = (e: any) => {
|
||||
console.error('Pusher Error:', e)
|
||||
uni.showToast({ title: '推流失败,请检查摄像头权限', icon: 'none' })
|
||||
}
|
||||
|
||||
const onPlayerError = (e: any, userId: string) => {
|
||||
console.error(`Player Error [${userId}]:`, e)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.call-window {
|
||||
.mp-call-container {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: #1c1c1e;
|
||||
z-index: 9999;
|
||||
|
||||
&.minimized {
|
||||
top: 100rpx;
|
||||
right: 20rpx;
|
||||
width: 240rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 40rpx;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
backdrop-filter: blur(20rpx);
|
||||
}
|
||||
|
||||
&:not(.minimized) {
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: #000;
|
||||
}
|
||||
}
|
||||
|
||||
.minimized-view {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10rpx 20rpx;
|
||||
height: 100%;
|
||||
|
||||
.mini-avatar {
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: var(--wd-color-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.avatar-text {
|
||||
color: #fff;
|
||||
font-size: 28rpx;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.mini-info {
|
||||
margin-left: 16rpx;
|
||||
flex: 1;
|
||||
|
||||
.mini-duration {
|
||||
color: #fff;
|
||||
font-size: 28rpx;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.mini-status {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 22rpx;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fullscreen-view {
|
||||
.video-layer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.video-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
background: #1a1a1a;
|
||||
z-index: 1; /* 视频层在底层 */
|
||||
}
|
||||
|
||||
.remote-videos {
|
||||
.remote-grid {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.remote-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.video-overlay {
|
||||
position: absolute;
|
||||
bottom: 20rpx;
|
||||
left: 20rpx;
|
||||
|
||||
.user-name {
|
||||
color: #fff;
|
||||
font-size: 28rpx;
|
||||
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
}
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
&.grid-single .live-player-item { width: 100%; height: 100%; }
|
||||
&.grid-multi .live-player-item { width: 50%; height: 50%; }
|
||||
}
|
||||
|
||||
.remote-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
.live-player-item { position: relative; }
|
||||
|
||||
.player-tag {
|
||||
position: absolute;
|
||||
bottom: 20rpx;
|
||||
left: 20rpx;
|
||||
background: rgba(0,0,0,0.5);
|
||||
padding: 8rpx 16rpx;
|
||||
border-radius: 8rpx;
|
||||
z-index: 10;
|
||||
}
|
||||
.tag-text { font-size: 24rpx; color: #fff; }
|
||||
|
||||
.local-pusher {
|
||||
position: absolute;
|
||||
right: 30rpx;
|
||||
top: 180rpx;
|
||||
width: 200rpx;
|
||||
height: 300rpx;
|
||||
border-radius: 16rpx;
|
||||
z-index: 10;
|
||||
border: 2rpx solid rgba(255,255,255,0.2);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.waiting-placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: #2c2c2e;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.caller-avatar-large {
|
||||
width: 200rpx;
|
||||
height: 200rpx;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: var(--wd-color-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 40rpx;
|
||||
|
||||
image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.avatar-text {
|
||||
color: #fff;
|
||||
font-size: 80rpx;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.caller-name {
|
||||
color: #fff;
|
||||
font-size: 40rpx;
|
||||
font-weight: bold;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.call-status {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 28rpx;
|
||||
}
|
||||
}
|
||||
.placeholder-avatar { width: 160rpx; height: 160rpx; border-radius: 50%; margin-bottom: 20rpx; }
|
||||
.placeholder-text { color: #aaa; font-size: 30rpx; }
|
||||
|
||||
.local-video-container {
|
||||
/* 顶部栏 - 使用标准 view */
|
||||
.ui-header {
|
||||
position: absolute;
|
||||
top: 100rpx;
|
||||
right: 20rpx;
|
||||
width: 200rpx;
|
||||
height: 280rpx;
|
||||
border-radius: 20rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.3);
|
||||
|
||||
.local-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.call-info {
|
||||
position: absolute;
|
||||
top: 40rpx;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
text-align: center;
|
||||
padding-top: env(safe-area-inset-top);
|
||||
|
||||
.duration {
|
||||
color: #fff;
|
||||
font-size: 48rpx;
|
||||
font-weight: bold;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 28rpx;
|
||||
margin-top: 10rpx;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.controls {
|
||||
padding: 40rpx 40rpx calc(40rpx + env(safe-area-inset-bottom));
|
||||
background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
|
||||
}
|
||||
|
||||
.incoming-controls {
|
||||
width: 100%;
|
||||
height: 160rpx;
|
||||
padding-top: 60rpx;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
|
||||
.control-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 30rpx;
|
||||
border-radius: 50%;
|
||||
|
||||
&.reject {
|
||||
background: #ff4d4f;
|
||||
}
|
||||
|
||||
&.accept {
|
||||
background: #52c41a;
|
||||
}
|
||||
|
||||
text {
|
||||
color: #fff;
|
||||
font-size: 24rpx;
|
||||
margin-top: 10rpx;
|
||||
}
|
||||
}
|
||||
justify-content: space-between;
|
||||
padding-left: 30rpx;
|
||||
padding-right: 30rpx;
|
||||
background: linear-gradient(180deg, rgba(0,0,0,0.6) 0%, transparent 100%);
|
||||
z-index: 100; /* 高于视频层 */
|
||||
pointer-events: none; /* 让点击穿透到视频层(如果有交互) */
|
||||
}
|
||||
.header-left { display: flex; align-items: center; pointer-events: auto; }
|
||||
.status-dot { width: 12rpx; height: 12rpx; background: #10b981; border-radius: 50%; margin-right: 16rpx; }
|
||||
.header-title { color: #fff; font-size: 32rpx; font-weight: bold; }
|
||||
.header-time { color: rgba(255,255,255,0.8); font-size: 26rpx; margin-left: 20rpx; }
|
||||
.minimize-btn { pointer-events: auto; padding: 10rpx; }
|
||||
|
||||
/* 底部栏 */
|
||||
.ui-controls {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 240rpx;
|
||||
background: linear-gradient(0deg, rgba(0,0,0,0.8) 0%, transparent 100%);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-bottom: 40rpx;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.call-controls {
|
||||
.active-actions, .incoming-actions {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
|
||||
.control-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20rpx;
|
||||
border-radius: 20rpx;
|
||||
color: #fff;
|
||||
|
||||
&.active {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
&.hangup {
|
||||
background: #ff4d4f;
|
||||
padding: 24rpx 40rpx;
|
||||
border-radius: 50rpx;
|
||||
}
|
||||
|
||||
text {
|
||||
font-size: 22rpx;
|
||||
margin-top: 8rpx;
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.minimize-btn {
|
||||
position: absolute;
|
||||
top: 40rpx;
|
||||
left: 40rpx;
|
||||
padding: 16rpx;
|
||||
.ctrl-btn-wrap { display: flex; flex-direction: column; align-items: center; }
|
||||
.ctrl-btn {
|
||||
width: 110rpx;
|
||||
height: 110rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: #fff;
|
||||
padding-top: calc(16rpx + env(safe-area-inset-top));
|
||||
background: rgba(255,255,255,0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 12rpx;
|
||||
transition: all 0.2s;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.ctrl-btn.active { background: #fff; }
|
||||
.ctrl-btn.hangup { background: #ef4444; }
|
||||
|
||||
.unsupported-tip {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
padding: 40rpx;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
border-radius: 20rpx;
|
||||
|
||||
text {
|
||||
color: #fff;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
.ctrl-text { font-size: 24rpx; color: rgba(255,255,255,0.8); }
|
||||
|
||||
.action-btn { display: flex; flex-direction: column; align-items: center; gap: 20rpx; }
|
||||
.btn-circle {
|
||||
width: 130rpx;
|
||||
height: 130rpx;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.reject-bg { background: rgba(255,255,255,0.2); backdrop-filter: blur(10px); }
|
||||
.accept-bg { background: #10b981; box-shadow: 0 4rpx 20rpx rgba(16, 185, 129, 0.4); }
|
||||
.action-text { color: #fff; font-size: 28rpx; }
|
||||
|
||||
/* 最小化球 */
|
||||
.minimized-ball {
|
||||
position: absolute;
|
||||
top: 200rpx;
|
||||
right: 30rpx;
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(0,0,0,0.8);
|
||||
border: 2rpx solid rgba(255,255,255,0.3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 101;
|
||||
}
|
||||
.ball-avatar { width: 60rpx; height: 60rpx; border-radius: 50%; margin-bottom: 4rpx; }
|
||||
.ball-time { color: #10b981; font-size: 20rpx; }
|
||||
</style>
|
||||
|
||||
|
||||
@@ -247,8 +247,8 @@ function handleClose() {
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: calc(88rpx + var(--status-bar-height, 0));
|
||||
padding-top: var(--status-bar-height, 0);
|
||||
height: calc(88rpx + var(--status-bar-height, 0) + var(--mp-safe-top, 0px));
|
||||
padding-top: calc(var(--status-bar-height, 0) + var(--mp-safe-top, 0px));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -113,7 +113,7 @@ function handleClick(key: string) {
|
||||
// ==========================================
|
||||
.plus-menu {
|
||||
position: fixed;
|
||||
top: calc(var(--status-bar-height, 20px) + 180rpx);
|
||||
top: calc(var(--status-bar-height, 20px) + var(--mp-safe-top, 0px) + 180rpx);
|
||||
right: 32rpx;
|
||||
width: 288rpx;
|
||||
background: var(--menu-bg);
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
/**
|
||||
* 小程序音视频通话组合式函数
|
||||
* 使用 live-pusher/live-player 组件
|
||||
*
|
||||
* 适用平台:微信小程序
|
||||
* * 适用平台:微信小程序
|
||||
*/
|
||||
import { reactive, ref, computed } from 'vue'
|
||||
import { reactive, ref, computed, nextTick } from 'vue'
|
||||
import * as callApi from '@/api/modules/call'
|
||||
import * as messageApi from '@/api/modules/message'
|
||||
import { wsManager } from '@/api/websocket'
|
||||
@@ -70,10 +69,20 @@ export function useMiniProgramCall() {
|
||||
|
||||
/**
|
||||
* 初始化 live-pusher 上下文
|
||||
* 修复:接收组件实例,并绑定具体的 ID 'local-pusher'
|
||||
* @param componentInstance Vue组件实例 (getCurrentInstance())
|
||||
*/
|
||||
function initPusherContext() {
|
||||
function initPusherContext(componentInstance?: any) {
|
||||
// #ifdef MP-WEIXIN
|
||||
pusherContext = uni.createLivePusherContext()
|
||||
// 关键修复:必须指定 id="local-pusher" 和 组件实例
|
||||
// 如果不传 componentInstance,在自定义组件中会找不到 live-pusher
|
||||
if (!componentInstance) {
|
||||
console.warn('[MiniProgramCall] initPusherContext: 缺少组件实例,推流可能失败')
|
||||
}
|
||||
|
||||
// 这里的 'local-pusher' 必须与 template 中的 <live-pusher id="local-pusher"> 一致
|
||||
pusherContext = uni.createLivePusherContext('local-pusher', componentInstance)
|
||||
console.log('[MiniProgramCall] Pusher Context Initialized', !!pusherContext)
|
||||
// #endif
|
||||
}
|
||||
|
||||
@@ -99,7 +108,7 @@ export function useMiniProgramCall() {
|
||||
if (signal === 'invite') {
|
||||
// 收到来电
|
||||
if (call.active) return
|
||||
|
||||
|
||||
currentReceiverUserId = message.sender_user_id
|
||||
call.roomId = message.room_id
|
||||
call.callId = message.call_id
|
||||
@@ -132,13 +141,16 @@ export function useMiniProgramCall() {
|
||||
} else if (signal === 'participant_joined') {
|
||||
// 新参与者加入
|
||||
if (content.pull_url) {
|
||||
remoteStreams.value.push({
|
||||
userId: content.user_id,
|
||||
pullUrl: content.pull_url,
|
||||
flvUrl: content.flv_url,
|
||||
userName: content.user_name,
|
||||
userAvatar: content.user_avatar,
|
||||
})
|
||||
const exists = remoteStreams.value.some(s => s.userId === content.user_id)
|
||||
if (!exists) {
|
||||
remoteStreams.value.push({
|
||||
userId: content.user_id,
|
||||
pullUrl: content.pull_url,
|
||||
flvUrl: content.flv_url,
|
||||
userName: content.user_name,
|
||||
userAvatar: content.user_avatar,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
} else if (signal === 'participant_left') {
|
||||
@@ -163,10 +175,10 @@ export function useMiniProgramCall() {
|
||||
* 发起通话
|
||||
*/
|
||||
async function startCall(
|
||||
type: 'audio' | 'video',
|
||||
receiverUserId: string,
|
||||
roomId: string,
|
||||
contact?: Contact
|
||||
type: 'audio' | 'video',
|
||||
receiverUserId: string,
|
||||
roomId: string,
|
||||
contact?: Contact
|
||||
): Promise<boolean> {
|
||||
if (call.active) {
|
||||
uni.showToast({ title: '当前有正在进行的通话', icon: 'none' })
|
||||
@@ -234,8 +246,12 @@ export function useMiniProgramCall() {
|
||||
}))
|
||||
}
|
||||
|
||||
// 开始推流
|
||||
startPushing()
|
||||
// 关键修复:使用 nextTick 确保 DOM 更新,live-pusher 的 url 属性生效后再启动
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
startPushing()
|
||||
}, 500) // 延迟500ms确保组件渲染完成
|
||||
})
|
||||
|
||||
// 开始计时
|
||||
startCallTimer()
|
||||
@@ -331,15 +347,34 @@ export function useMiniProgramCall() {
|
||||
*/
|
||||
function startPushing() {
|
||||
// #ifdef MP-WEIXIN
|
||||
if (!pusherContext) {
|
||||
initPusherContext()
|
||||
// 检测是否在开发者工具中运行
|
||||
const systemInfo = uni.getSystemInfoSync()
|
||||
if (systemInfo.platform === 'devtools') {
|
||||
console.warn('[MiniProgramCall] 开发者工具不支持 live-pusher,请在真机上测试')
|
||||
// 仅提示一次,避免阻塞流程
|
||||
}
|
||||
pusherContext?.start({
|
||||
|
||||
if (!pusherContext) {
|
||||
console.error('[MiniProgramCall] Pusher Context 未初始化,无法推流')
|
||||
return
|
||||
}
|
||||
|
||||
// 确保 URL 存在
|
||||
if (!pushUrl.value) {
|
||||
console.error('[MiniProgramCall] 推流地址为空')
|
||||
return
|
||||
}
|
||||
|
||||
pusherContext.start({
|
||||
success: () => {
|
||||
console.log('[MiniProgramCall] 推流成功')
|
||||
console.log('[MiniProgramCall] 推流启动成功')
|
||||
},
|
||||
fail: (err: any) => {
|
||||
console.error('[MiniProgramCall] 推流失败:', err)
|
||||
console.error('[MiniProgramCall] 推流启动失败:', err)
|
||||
// 提示用户可能的原因
|
||||
if (err.errMsg?.includes('operateLivePusher:fail')) {
|
||||
uni.showToast({ title: '推流失败,请检查网络或权限', icon: 'none' })
|
||||
}
|
||||
}
|
||||
})
|
||||
// #endif
|
||||
@@ -433,7 +468,8 @@ export function useMiniProgramCall() {
|
||||
function toggleMute() {
|
||||
call.muted = !call.muted
|
||||
// #ifdef MP-WEIXIN
|
||||
pusherContext?.mute(call.muted)
|
||||
// 依赖 template 中的 :enable-mic="!call.muted"
|
||||
// 或者可以使用 API: pusherContext?.setMicVolume({ volume: call.muted ? 0 : 1 })
|
||||
// #endif
|
||||
}
|
||||
|
||||
@@ -443,6 +479,8 @@ export function useMiniProgramCall() {
|
||||
function toggleCamera() {
|
||||
call.camOff = !call.camOff
|
||||
// #ifdef MP-WEIXIN
|
||||
// 依赖 template 中的 :enable-camera="!call.camOff"
|
||||
// 也可以手动控制 pause/resume
|
||||
if (call.camOff) {
|
||||
pusherContext?.pause()
|
||||
} else {
|
||||
@@ -499,10 +537,6 @@ export function useMiniProgramCall() {
|
||||
case -1302: // 打开麦克风失败
|
||||
uni.showToast({ title: '麦克风打开失败', icon: 'none' })
|
||||
break
|
||||
case -1303: // 视频编码失败
|
||||
break
|
||||
case -1304: // 音频编码失败
|
||||
break
|
||||
case -1307: // 推流连接断开
|
||||
call.statusText = '连接已断开'
|
||||
break
|
||||
@@ -516,16 +550,7 @@ export function useMiniProgramCall() {
|
||||
const code = e.detail?.code
|
||||
console.log(`[MiniProgramCall] 拉流状态 [${userId}]:`, code)
|
||||
|
||||
switch (code) {
|
||||
case 2002: // 已连接到服务器
|
||||
break
|
||||
case 2003: // 收到首帧视频
|
||||
break
|
||||
case 2004: // 收到首帧音频
|
||||
break
|
||||
case -2301: // 网络断开,正在重连
|
||||
break
|
||||
}
|
||||
// 状态码参考微信文档
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -550,4 +575,3 @@ export function useMiniProgramCall() {
|
||||
}
|
||||
|
||||
export default useMiniProgramCall
|
||||
|
||||
|
||||
@@ -1,70 +1,90 @@
|
||||
{
|
||||
"name": "",
|
||||
"appid": "",
|
||||
"description": "",
|
||||
"versionName": "1.0.0",
|
||||
"versionCode": "100",
|
||||
"transformPx": false,
|
||||
"uniStatistics": {
|
||||
"enable": false
|
||||
},
|
||||
"app-plus": {
|
||||
"usingComponents": true,
|
||||
"nvueStyleCompiler": "uni-app",
|
||||
"compilerVersion": 3,
|
||||
"splashscreen": {
|
||||
"alwaysShowBeforeRender": true,
|
||||
"waiting": true,
|
||||
"autoclose": true,
|
||||
"delay": 0
|
||||
"name" : "奶酪IM",
|
||||
"appid" : "__UNI__C5C6003",
|
||||
"description" : "",
|
||||
"versionName" : "1.0.0",
|
||||
"versionCode" : "100",
|
||||
"transformPx" : false,
|
||||
"uniStatistics" : {
|
||||
"enable" : false
|
||||
},
|
||||
"modules": {},
|
||||
"distribute": {
|
||||
"android": {
|
||||
"permissions": [
|
||||
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
|
||||
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
|
||||
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
|
||||
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
|
||||
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
|
||||
"<uses-feature android:name=\"android.hardware.camera\"/>",
|
||||
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
|
||||
]
|
||||
},
|
||||
"ios": {},
|
||||
"sdkConfigs": {}
|
||||
}
|
||||
},
|
||||
"h5": {
|
||||
"darkmode": true,
|
||||
"themeLocation": "theme.json"
|
||||
},
|
||||
"quickapp": {},
|
||||
"mp-weixin": {
|
||||
"appid": "wx54452d2c36bec8ee",
|
||||
"setting": {
|
||||
"urlCheck": false
|
||||
"app-plus" : {
|
||||
"usingComponents" : true,
|
||||
"nvueStyleCompiler" : "uni-app",
|
||||
"compilerVersion" : 3,
|
||||
"splashscreen" : {
|
||||
"alwaysShowBeforeRender" : true,
|
||||
"waiting" : true,
|
||||
"autoclose" : true,
|
||||
"delay" : 0
|
||||
},
|
||||
"modules" : {},
|
||||
"distribute" : {
|
||||
"android" : {
|
||||
"permissions" : [
|
||||
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
|
||||
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
|
||||
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
|
||||
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
|
||||
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
|
||||
"<uses-feature android:name=\"android.hardware.camera\"/>",
|
||||
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
|
||||
]
|
||||
},
|
||||
"ios" : {
|
||||
"dSYMs" : false
|
||||
},
|
||||
"sdkConfigs" : {}
|
||||
}
|
||||
},
|
||||
"usingComponents": true,
|
||||
"darkmode": true,
|
||||
"themeLocation": "theme.json"
|
||||
},
|
||||
"mp-alipay": {
|
||||
"usingComponents": true
|
||||
},
|
||||
"mp-baidu": {
|
||||
"usingComponents": true
|
||||
},
|
||||
"mp-toutiao": {
|
||||
"usingComponents": true
|
||||
},
|
||||
"vueVersion": "3"
|
||||
"h5" : {
|
||||
"darkmode" : true,
|
||||
"themeLocation" : "theme.json"
|
||||
},
|
||||
"quickapp" : {},
|
||||
"mp-weixin" : {
|
||||
"appid" : "wx1eb90a487ca945d2",
|
||||
"__usePrivacyCheck__" : true,
|
||||
"setting" : {
|
||||
"urlCheck" : false,
|
||||
"preloadBackgroundData" : false
|
||||
},
|
||||
"libVersion" : "2.7.0",
|
||||
"usingComponents" : true,
|
||||
"darkmode" : true,
|
||||
"themeLocation" : "theme.json",
|
||||
"permission" : {
|
||||
"scope.userLocation" : {
|
||||
"desc" : "用于获取您的位置信息"
|
||||
},
|
||||
"scope.record" : {
|
||||
"desc" : "用于语音消息录制"
|
||||
},
|
||||
"scope.camera" : {
|
||||
"desc" : "用于拍摄照片和视频通话"
|
||||
},
|
||||
"scope.writePhotosAlbum" : {
|
||||
"desc" : "用于保存图片到相册"
|
||||
}
|
||||
},
|
||||
"requiredPrivateInfos" : [ "chooseImage", "chooseVideo", "chooseLocation", "getLocation" ]
|
||||
},
|
||||
"mp-alipay" : {
|
||||
"usingComponents" : true
|
||||
},
|
||||
"mp-baidu" : {
|
||||
"usingComponents" : true
|
||||
},
|
||||
"mp-toutiao" : {
|
||||
"usingComponents" : true
|
||||
},
|
||||
"vueVersion" : "3"
|
||||
}
|
||||
|
||||
@@ -65,9 +65,9 @@
|
||||
|
||||
<!-- 群通话 Banner -->
|
||||
<group-call-banner
|
||||
v-if="isGroupChat && hasActiveGroupCall"
|
||||
:room-id="roomId"
|
||||
class="call-banner-wrapper"
|
||||
v-if="isGroupChat && hasActiveGroupCall"
|
||||
:room-id="roomId"
|
||||
class="call-banner-wrapper"
|
||||
/>
|
||||
|
||||
<!-- 2. 消息列表区 -->
|
||||
@@ -165,7 +165,7 @@
|
||||
</wd-popup>
|
||||
|
||||
<wd-toast />
|
||||
|
||||
|
||||
<!-- 全局通话组件 -->
|
||||
<global-call-provider />
|
||||
</view>
|
||||
@@ -213,9 +213,9 @@ const groupWebRTC = useGroupWebRTC()
|
||||
// 检查群聊是否有进行中的通话
|
||||
const hasActiveGroupCall = computed(() => {
|
||||
if (!isGroupChat.value || !roomId.value) return false
|
||||
return groupWebRTC.callState.groupId === roomId.value &&
|
||||
!groupWebRTC.callState.joined &&
|
||||
groupWebRTC.callState.roomId !== ''
|
||||
return groupWebRTC.callState.groupId === roomId.value &&
|
||||
!groupWebRTC.callState.joined &&
|
||||
groupWebRTC.callState.roomId !== ''
|
||||
})
|
||||
// 获取当前组件实例,用于 SelectorQuery
|
||||
const instance = getCurrentInstance()
|
||||
@@ -246,12 +246,12 @@ onLoad(async (options: any) => {
|
||||
isGroupChat.value = !targetId.value && !!roomId.value;
|
||||
|
||||
setupWebSocket();
|
||||
|
||||
|
||||
// 群聊时先加载成员,确保消息渲染时能获取到发送者信息
|
||||
if (isGroupChat.value && roomId.value) {
|
||||
await loadGroupMembers()
|
||||
}
|
||||
|
||||
|
||||
loadMessages();
|
||||
});
|
||||
|
||||
@@ -460,32 +460,32 @@ function onScrollClick() { /* 点击空白处收起键盘 */ }
|
||||
function getMessageSenderAvatar(msg: ChatMessage): string {
|
||||
const msgAny = msg as any
|
||||
const senderId = msg.sender_user_id
|
||||
|
||||
|
||||
// 1. 优先从消息体中获取(后端可能返回sender信息)
|
||||
if (msgAny.sender?.avatar) return resolveImageUrl(msgAny.sender.avatar)
|
||||
if (msgAny.sender_avatar) return resolveImageUrl(msgAny.sender_avatar)
|
||||
if (msgAny.from_user?.avatar) return resolveImageUrl(msgAny.from_user.avatar)
|
||||
|
||||
|
||||
// 2. 群聊场景
|
||||
if (isGroupChat.value && senderId) {
|
||||
// 2.1 从缓存获取
|
||||
const cachedUser = userCache.value.get(senderId)
|
||||
if (cachedUser?.avatar) return resolveImageUrl(cachedUser.avatar)
|
||||
|
||||
|
||||
// 2.2 从成员映射获取(O(1)查找)
|
||||
const member = groupMembersMap.value.get(senderId)
|
||||
if (member) {
|
||||
const avatar = member.user?.avatar || (member as any).avatar || (member as any).user_avatar
|
||||
if (avatar) return resolveImageUrl(avatar)
|
||||
}
|
||||
|
||||
|
||||
// 2.3 从联系人列表获取
|
||||
const contact = chatStore.contacts.find(c => c.contact_user_id === senderId)
|
||||
if (contact?.user?.avatar) return resolveImageUrl(contact.user.avatar)
|
||||
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
|
||||
// 3. 单聊场景
|
||||
return resolveImageUrl(targetUser.value?.avatar || '')
|
||||
}
|
||||
@@ -494,33 +494,33 @@ function getMessageSenderAvatar(msg: ChatMessage): string {
|
||||
function getMessageSenderName(msg: ChatMessage): string {
|
||||
const msgAny = msg as any
|
||||
const senderId = msg.sender_user_id
|
||||
|
||||
|
||||
// 1. 优先从消息体中获取
|
||||
if (msgAny.sender?.name) return msgAny.sender.name
|
||||
if (msgAny.sender_name) return msgAny.sender_name
|
||||
if (msgAny.from_user?.name) return msgAny.from_user.name
|
||||
|
||||
|
||||
// 2. 群聊场景
|
||||
if (isGroupChat.value && senderId) {
|
||||
// 2.1 从缓存获取
|
||||
const cachedUser = userCache.value.get(senderId)
|
||||
if (cachedUser?.name) return cachedUser.name
|
||||
|
||||
|
||||
// 2.2 从成员映射获取(O(1)查找)
|
||||
const member = groupMembersMap.value.get(senderId)
|
||||
if (member) {
|
||||
const name = member.nickname || member.user?.name || (member as any).name || (member as any).user_name
|
||||
if (name) return name
|
||||
}
|
||||
|
||||
|
||||
// 2.3 从联系人列表获取
|
||||
const contact = chatStore.contacts.find(c => c.contact_user_id === senderId)
|
||||
if (contact?.user?.name) return contact.remark_name || contact.user.name
|
||||
|
||||
|
||||
// 2.4 兜底:显示用户ID末4位
|
||||
return `用户${senderId.slice(-4)}`
|
||||
}
|
||||
|
||||
|
||||
// 3. 单聊场景
|
||||
return targetUser.value?.name || chatName.value || '未知'
|
||||
}
|
||||
@@ -531,20 +531,94 @@ function playVideo(url: string) { if (url) { /* #ifdef H5 */ window.open(url, '_
|
||||
function openFile(url: string) { if (!url) return; /* #ifdef H5 */ window.open(url, '_blank'); /* #endif */ /* #ifndef H5 */ toast.loading('正在打开文件...'); uni.downloadFile({ url: url, success: (res) => { toast.close(); if (res.statusCode === 200) { uni.openDocument({ filePath: res.tempFilePath, showMenu: true }) } }, fail: () => { toast.close(); toast.error('下载失败') } }); /* #endif */ }
|
||||
let audioContext: UniApp.InnerAudioContext | null = null; function playAudio(msg: ChatMessage) { const audioUrl = typeof msg.extra === 'string' ? JSON.parse(msg.extra).url : msg.extra?.url || msg.content; const url = resolveImageUrl(audioUrl); if (!url) return; if (playingAudioId.value === msg.id) { stopAudio(); return } stopAudio(); audioContext = uni.createInnerAudioContext(); audioContext.src = url; audioContext.autoplay = true; playingAudioId.value = msg.id; audioContext.onEnded(() => { playingAudioId.value = null }); audioContext.onError(() => { playingAudioId.value = null; toast.error('播放失败') }) }
|
||||
function stopAudio() { if (audioContext) { audioContext.stop(); audioContext.destroy(); audioContext = null } playingAudioId.value = null }
|
||||
function startAudioCall() {
|
||||
|
||||
// --- 3. 小程序权限检查与通话启动逻辑 ---
|
||||
|
||||
/**
|
||||
* 检查小程序通话权限
|
||||
* 顺序:scope.record (麦克风) -> scope.camera (摄像头,仅视频通话需要)
|
||||
*/
|
||||
async function checkCallPermissions(type: 'audio' | 'video'): Promise<boolean> {
|
||||
// #ifndef MP-WEIXIN
|
||||
return true // 非小程序环境默认认为有权限(由浏览器控制)
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
const scopes = ['scope.record']
|
||||
if (type === 'video') {
|
||||
scopes.push('scope.camera')
|
||||
}
|
||||
|
||||
for (const scope of scopes) {
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
uni.authorize({
|
||||
scope,
|
||||
success: resolve,
|
||||
fail: reject
|
||||
})
|
||||
})
|
||||
} catch (e) {
|
||||
// 授权失败(用户拒绝或未授权)
|
||||
const scopeName = scope === 'scope.camera' ? '摄像头' : '麦克风'
|
||||
const res = await uni.showModal({
|
||||
title: '权限申请',
|
||||
content: `需要获取您的${scopeName}权限才能进行通话,请在设置中开启`,
|
||||
confirmText: '去设置',
|
||||
showCancel: true
|
||||
})
|
||||
|
||||
if (res.confirm) {
|
||||
// 引导用户打开设置页
|
||||
const settingRes = await new Promise<any>((resolve) => {
|
||||
uni.openSetting({
|
||||
success: resolve,
|
||||
fail: () => resolve({ authSetting: {} })
|
||||
})
|
||||
})
|
||||
|
||||
// 再次检查设置结果
|
||||
if (!settingRes.authSetting[scope]) {
|
||||
toast.error(`未获取${scopeName}权限,无法通话`)
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
// 用户点击取消
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
// #endif
|
||||
}
|
||||
|
||||
async function startAudioCall() {
|
||||
if (!targetId.value) return
|
||||
|
||||
// 1. 权限检查
|
||||
const granted = await checkCallPermissions('audio')
|
||||
if (!granted) return
|
||||
|
||||
// 2. 启动通话
|
||||
// #ifdef H5 || APP-PLUS
|
||||
webrtc.startCall('audio', targetId.value, roomId.value, targetUser.value?.name, targetUser.value?.avatar)
|
||||
// #endif
|
||||
// #ifdef MP-WEIXIN
|
||||
mpCall.startCall('audio', targetId.value, roomId.value, {
|
||||
user_id: targetId.value,
|
||||
user: { name: targetUser.value?.name, avatar: targetUser.value?.avatar }
|
||||
mpCall.startCall('audio', targetId.value, roomId.value, {
|
||||
user_id: targetId.value,
|
||||
user: { name: targetUser.value?.name, avatar: targetUser.value?.avatar }
|
||||
} as any)
|
||||
// #endif
|
||||
}
|
||||
function startVideoCall() {
|
||||
|
||||
async function startVideoCall() {
|
||||
if (!targetId.value) return
|
||||
|
||||
// 1. 权限检查
|
||||
const granted = await checkCallPermissions('video')
|
||||
if (!granted) return
|
||||
|
||||
// 2. 启动通话
|
||||
// #ifdef H5 || APP-PLUS
|
||||
webrtc.startCall('video', targetId.value, roomId.value, targetUser.value?.name, targetUser.value?.avatar)
|
||||
// #endif
|
||||
@@ -555,6 +629,7 @@ function startVideoCall() {
|
||||
} as any)
|
||||
// #endif
|
||||
}
|
||||
|
||||
function chooseImage() { uni.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['album'], success: handleFileSelect('image', 1) }) }
|
||||
function shootCamera() { uni.chooseImage({ count: 1, sourceType: ['camera'], success: handleFileSelect('image', 1) }) }
|
||||
function chooseFile() { /* #ifdef MP-WEIXIN */ wx.chooseMessageFile({ count: 1, type: 'file', success: (res: any) => handleFileSelect('file', 8)({ tempFilePaths: [res.tempFiles[0].path], tempFiles: res.tempFiles }) }); /* #endif */ /* #ifdef APP-PLUS */ uni.chooseFile({ count: 1, success: handleFileSelect('file', 8) }); /* #endif */ /* #ifdef H5 */ const input = document.createElement('input'); input.type = 'file'; input.onchange = (e: any) => { const file = e.target.files[0]; if(file) { pendingFile.value = { path: URL.createObjectURL(file), name: file.name, size: file.size, type: 'file', messageType: 8 }; showFileConfirm.value = true } }; input.click(); /* #endif */ }
|
||||
@@ -614,7 +689,7 @@ function cancelRecording() { isCancelRecording.value = true; stopRecording() }
|
||||
/* 导航栏 */
|
||||
.glass-navbar {
|
||||
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
|
||||
padding-top: var(--status-bar-height);
|
||||
padding-top: calc(var(--status-bar-height) + var(--mp-safe-top, 0px));
|
||||
.navbar-bg {
|
||||
position: absolute; inset: 0; background: var(--nav-bg);
|
||||
backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
|
||||
@@ -648,7 +723,7 @@ function cancelRecording() { isCancelRecording.value = true; stopRecording() }
|
||||
/* 群通话 Banner 位置 */
|
||||
.call-banner-wrapper {
|
||||
position: fixed;
|
||||
top: calc(88rpx + var(--status-bar-height));
|
||||
top: calc(88rpx + var(--status-bar-height) + var(--mp-safe-top, 0px));
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 99;
|
||||
@@ -662,13 +737,13 @@ function cancelRecording() { isCancelRecording.value = true; stopRecording() }
|
||||
|
||||
&.has-banner {
|
||||
.message-feed {
|
||||
padding-top: calc(88rpx + var(--status-bar-height) + 160rpx);
|
||||
padding-top: calc(88rpx + var(--status-bar-height) + var(--mp-safe-top, 0px) + 160rpx);
|
||||
}
|
||||
}
|
||||
}
|
||||
.message-feed {
|
||||
padding: 32rpx;
|
||||
padding-top: calc(88rpx + var(--status-bar-height) + 32rpx);
|
||||
padding-top: calc(88rpx + var(--status-bar-height) + var(--mp-safe-top, 0px) + 32rpx);
|
||||
/* 增加底部 Padding 以防输入框遮挡 */
|
||||
padding-bottom: 240rpx;
|
||||
}
|
||||
|
||||
@@ -504,7 +504,7 @@ function goUserDetail(user: User) {
|
||||
.header-content {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
padding-top: var(--status-bar-height);
|
||||
padding-top: calc(var(--status-bar-height) + var(--mp-safe-top, 0px));
|
||||
padding-bottom: 40rpx;
|
||||
}
|
||||
|
||||
|
||||
@@ -445,7 +445,7 @@ async function deleteFriend() { try { await messageBox.confirm({ title: '提示'
|
||||
.nav-header {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
padding: calc(var(--status-bar-height) + 24rpx) 32rpx 16rpx;
|
||||
padding: calc(var(--status-bar-height) + var(--mp-safe-top, 0px) + 24rpx) 32rpx 16rpx;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
@@ -492,7 +492,7 @@ async function logout() { try { await messageBox.confirm({ title: '提示', msg:
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
background: var(--bg-page);
|
||||
padding-top: var(--status-bar-height);
|
||||
padding-top: calc(var(--status-bar-height) + var(--mp-safe-top, 0px));
|
||||
// 与消息页一致,不要下边框
|
||||
border-bottom: none;
|
||||
transition: background 0.3s;
|
||||
@@ -641,8 +641,8 @@ async function logout() { try { await messageBox.confirm({ title: '提示', msg:
|
||||
|
||||
// 滚动区
|
||||
.main-scroll {
|
||||
margin-top: calc(var(--status-bar-height) + 290rpx);
|
||||
height: calc(100vh - var(--status-bar-height) - 290rpx);
|
||||
margin-top: calc(var(--status-bar-height) + var(--mp-safe-top, 0px) + 290rpx);
|
||||
height: calc(100vh - var(--status-bar-height) - var(--mp-safe-top, 0px) - 290rpx);
|
||||
}
|
||||
|
||||
.scroll-spacer {
|
||||
|
||||
@@ -406,7 +406,7 @@ async function logout() {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
padding-top: var(--status-bar-height, 0);
|
||||
padding-top: calc(var(--status-bar-height, 0) + var(--mp-safe-top, 0px));
|
||||
// 确保没有任何边框
|
||||
border: none;
|
||||
|
||||
|
||||
@@ -394,7 +394,7 @@ function getImageSize(moment: Moment): string {
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: calc(88rpx + var(--status-bar-height));
|
||||
height: calc(88rpx + var(--status-bar-height) + var(--mp-safe-top, 0px));
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
|
||||
@@ -178,7 +178,7 @@ async function save() {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: calc(var(--status-bar-height) + 16rpx) 24rpx 16rpx;
|
||||
padding: calc(var(--status-bar-height) + var(--mp-safe-top, 0px) + 16rpx) 24rpx 16rpx;
|
||||
background: var(--bg-page);
|
||||
|
||||
.nav-back {
|
||||
|
||||
@@ -197,7 +197,7 @@ function goProfileEdit() { uni.navigateTo({ url: '/pages/profile/edit' }) }
|
||||
// 头部
|
||||
.profile-header {
|
||||
background: var(--bg-surface);
|
||||
padding: calc(var(--status-bar-height) + 24rpx) 40rpx 60rpx;
|
||||
padding: calc(var(--status-bar-height) + var(--mp-safe-top, 0px) + 24rpx) 40rpx 60rpx;
|
||||
border-bottom-left-radius: 80rpx;
|
||||
border-bottom-right-radius: 80rpx;
|
||||
box-shadow: 0 10rpx 40rpx rgba(0, 0, 0, 0.05);
|
||||
|
||||
@@ -436,7 +436,7 @@ function formatTime(time: string): string {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
padding: calc(var(--status-bar-height) + 16rpx) 24rpx 16rpx;
|
||||
padding: calc(var(--status-bar-height) + var(--mp-safe-top, 0px) + 16rpx) 24rpx 16rpx;
|
||||
background: var(--bg-page);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
|
||||
@@ -305,7 +305,7 @@ function handleClearCache() {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: calc(var(--status-bar-height) + 16rpx) 24rpx 16rpx;
|
||||
padding: calc(var(--status-bar-height) + var(--mp-safe-top, 0px) + 16rpx) 24rpx 16rpx;
|
||||
background: var(--bg-page);
|
||||
|
||||
.nav-back {
|
||||
|
||||
@@ -169,7 +169,7 @@ function selectTheme(mode: 'system' | 'light' | 'dark') {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: calc(var(--status-bar-height) + 16rpx) 24rpx 16rpx;
|
||||
padding: calc(var(--status-bar-height) + var(--mp-safe-top, 0px) + 16rpx) 24rpx 16rpx;
|
||||
background: var(--bg-page);
|
||||
|
||||
.nav-back {
|
||||
|
||||
7
src/privacy.json
Normal file
7
src/privacy.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"_comment": "微信小程序隐私协议配置文件",
|
||||
"privacyContractInfo": {
|
||||
"desc": "请在微信公众平台后台设置《用户隐私保护指引》"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,10 +184,18 @@
|
||||
--safe-area-bottom: env(safe-area-inset-bottom, 0px);
|
||||
--safe-area-left: env(safe-area-inset-left, 0px);
|
||||
--safe-area-right: env(safe-area-inset-right, 0px);
|
||||
// 微信小程序额外安全距离(胶囊按钮区域)
|
||||
--mp-safe-top: 0px;
|
||||
}
|
||||
|
||||
/* #ifdef MP-WEIXIN */
|
||||
page {
|
||||
--mp-safe-top: env(safe-area-inset-top, 20px);
|
||||
}
|
||||
/* #endif */
|
||||
|
||||
.safe-area-top {
|
||||
padding-top: var(--status-bar-height, 0);
|
||||
padding-top: calc(var(--status-bar-height, 0) + var(--mp-safe-top, 0px));
|
||||
}
|
||||
|
||||
.safe-area-bottom {
|
||||
@@ -441,7 +449,7 @@
|
||||
background: var(--nav-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
padding-top: var(--status-bar-height, 0);
|
||||
padding-top: calc(var(--status-bar-height, 0) + var(--mp-safe-top, 0px));
|
||||
border-bottom: 1rpx solid var(--border-subtle);
|
||||
|
||||
.navbar-content {
|
||||
|
||||
Reference in New Issue
Block a user