feat(meeting): Task 15 UI 打磨 + 主持人四件套 + 媒体层稳定性回归修复

- Task 15 UI:6 项原创特色(说话者流光 / 柔性网格 / 自视频浮窗 / 静音氛围色 / 入会滑入 / NetworkBadge 3 条波浪)+ 说话者双源探测(RTP audioLevel + WebAudio RMS)+ 主持人四件套(静音/开麦/转让/踢出)
- 新增 SelfVideoFloat 浮窗组件 + 4 份 design-system 页面文档(home / preview / room / invite)
- 媒体回归补丁(手工联调触发):
  * 后端 signal_service 在 OnRoomJoin 追加 pushExistingRoomState → 向新加入者补发历史 producers + 历史成员 audio/video 状态
  * 新增 Redis Hash memberStateKey 持久化成员 audio/video enabled,OnMemberStateChanged 落盘、cleanupUserResources 清理
  * 前端 _broadcastSelfState 在本地音视频开关末尾同步 state.changed;_afterJoined 先 getRoom 再 room.join 修复后入者成员状态时序;_onMemberStateChanged 占位兜底
  * _cleanupRemoteProducer 改为按 producerId 精准清理(修复 "关音频误关视频")
  * _onRoomEnded + createAndEnter/joinAndEnter 强化 reset(修复重入会看不到自己画面)
  * mediasoup-client ensureSend/RecvTransport 引入 in-flight Promise 锁防并发重复建连
- 文档同步:CURRENT_STATUS.md / Task 15 plan / phase2e-2 implementation plan

Made-with: Cursor
This commit is contained in:
bujinyuan
2026-04-23 16:04:46 +08:00
parent 46b37b8b4c
commit 790996feaf
26 changed files with 2043 additions and 151 deletions

View File

@@ -11,7 +11,7 @@
- 按钮带 loading 禁用期间点击不派发事件
-->
<template>
<view class="toolbar">
<view class="toolbar" :class="{ 'toolbar-all-muted': allMuted }">
<button class="btn" :class="{ active: audioEnabled, loading: audioLoading }" :disabled="audioLoading" @click="emitIf('mic-toggle')">
<view class="icon">
<svg v-if="audioEnabled" viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -100,7 +100,9 @@ const props = defineProps({
videoLoading: { type: Boolean, default: false },
memberCount: { type: Number, default: 0 },
unreadChatCount: { type: Number, default: 0 },
allowChat: { type: Boolean, default: true }
allowChat: { type: Boolean, default: true },
/** Task 15 原创特色 4全员静音氛围色 */
allMuted: { type: Boolean, default: false }
})
const emit = defineEmits(['mic-toggle', 'cam-toggle', 'invite', 'members', 'chat', 'leave'])
@@ -130,6 +132,13 @@ const emitIf = (name) => {
flex-shrink: 0;
position: relative;
z-index: 210;
transition: background 0.4s ease, border-top-color 0.4s ease;
}
/* Task 15 原创特色 4全员静音氛围色
冷蓝渐变暗示"全房间安静",触发条件由父组件 computed (allMuted) 控制 */
.toolbar.toolbar-all-muted {
background: linear-gradient(to top, rgba(30, 58, 138, 0.88), rgba(30, 64, 175, 0.78));
border-top-color: rgba(96, 165, 250, 0.18);
}
.btn {

View File

@@ -68,6 +68,20 @@
<circle cx="12" cy="19" r="1"></circle>
</svg>
<view v-if="openMenuUid === p.user_id" class="menu" @click.stop>
<view
v-if="p.audio_enabled !== false"
class="menu-item"
@click="onMute(p, true)"
>
<text>请他静音</text>
</view>
<view
v-else
class="menu-item"
@click="onMute(p, false)"
>
<text>请他开麦</text>
</view>
<view class="menu-item" @click="onTransfer(p)">
<text>转让主持人</text>
</view>
@@ -98,7 +112,7 @@ const props = defineProps({
displayNameMap: { type: Object, default: () => ({}) }
})
const emit = defineEmits(['close', 'kick', 'transfer-host'])
const emit = defineEmits(['close', 'kick', 'transfer-host', 'mute-member'])
const openMenuUid = ref(0)
const show = ref(false)
@@ -163,6 +177,12 @@ const onKick = (p) => {
emit('kick', p.user_id)
}
/** Task 15主持人权限四件套第 4 件 —— 请他静音 / 请他开麦 */
const onMute = (p, mute) => {
openMenuUid.value = 0
emit('mute-member', { userId: p.user_id, mute })
}
const close = () => emit('close')
const onMaskClick = () => close()
</script>

View File

@@ -1,19 +1,40 @@
<!--
网络质量徽标Task 11
网络质量徽标Task 11 / Task 15 波浪动效升级
职责
- 渲染 4 格信号柱类似手机信号图标根据 level (0-4) 点亮
- 可选展示文字标签默认隐藏hover/长按再显示
- 本组件不产生网络数据由父页面在 Task 15 接入 mediasoup getStats 后喂入
- Task 15 原创特色 6使用 3 条横向 SVG 波浪曲线代替传统格子信号条
- 根据 level (0-4) 动态点亮可见条数 + 颜色
- 3 条波浪以相位差 0 / -0.3s / -0.6s 循环流动形成"呼吸感"
- 可选文字标签title tooltip 保留默认不显示 inline label
level 语义
- 4 excellent / 3 good / 2 fair / 1 poor / 0 disconnected
-->
<template>
<view class="badge" :class="levelClass" :title="labelText">
<view v-for="n in 4" :key="n" class="bar" :class="{ on: n <= level }"
:style="{ height: `${n * 20}%` }"
></view>
<view v-if="level <= 0" class="dc-icon" aria-label="已断开">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="4" y1="12" x2="20" y2="12"></line>
<circle cx="12" cy="17" r="1" fill="currentColor"></circle>
</svg>
</view>
<template v-else>
<svg
v-for="n in visibleLines"
:key="n"
class="wave"
:class="`wave-${n}`"
viewBox="0 0 24 12"
width="24"
height="10"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
>
<path d="M 0 6 C 3 2, 6 10, 9 6 S 15 2, 18 6 S 22 10, 24 6" />
</svg>
</template>
<text v-if="showLabel" class="label">{{ labelText }}</text>
</view>
</template>
@@ -30,6 +51,14 @@ const LEVEL_LABEL = ['已断开', '很差', '一般', '良好', '优秀']
const labelText = computed(() => LEVEL_LABEL[Math.max(0, Math.min(4, props.level))])
/** level → 可见条数4=3 条 / 3=2 条 / 2/1=1 条0 由 dc-icon 承接) */
const visibleLines = computed(() => {
if (props.level >= 4) return 3
if (props.level >= 3) return 2
if (props.level >= 1) return 1
return 0
})
const levelClass = computed(() => {
if (props.level >= 4) return 'lv-excellent'
if (props.level >= 3) return 'lv-good'
@@ -42,28 +71,42 @@ const levelClass = computed(() => {
<style scoped>
.badge {
display: inline-flex;
align-items: flex-end;
gap: 3rpx;
height: 28rpx;
padding: 4rpx 8rpx;
border-radius: 8rpx;
align-items: center;
gap: 2rpx;
height: 36rpx;
padding: 4rpx 10rpx;
border-radius: 10rpx;
background: rgba(0, 0, 0, 0.35);
backdrop-filter: blur(4px);
position: relative;
}
.bar {
width: 6rpx;
background: rgba(255, 255, 255, 0.25);
border-radius: 2rpx;
transition: background-color 0.15s ease;
.wave {
animation: wave-flow 1.2s linear infinite;
flex-shrink: 0;
/* 三条波浪相位差:通过 animation-delay 负值错开 */
}
.wave-1 { animation-delay: 0s; }
.wave-2 { animation-delay: -0.3s; }
.wave-3 { animation-delay: -0.6s; }
@keyframes wave-flow {
from { transform: translateX(0); }
to { transform: translateX(-6px); }
}
.lv-excellent .bar.on { background: #10B981; }
.lv-good .bar.on { background: #22C55E; }
.lv-fair .bar.on { background: #F59E0B; }
.lv-poor .bar.on { background: #EF4444; }
.lv-off .bar { background: rgba(255, 255, 255, 0.2); }
.lv-excellent { color: #10B981; }
.lv-good { color: #22C55E; }
.lv-fair { color: #F59E0B; }
.lv-poor { color: #EF4444; }
.lv-off { color: #EF4444; }
.dc-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24rpx;
}
.label {
margin-left: 8rpx;
@@ -72,4 +115,9 @@ const levelClass = computed(() => {
line-height: 1;
align-self: center;
}
/* 可访问性:减少动效偏好下停止流动 */
@media (prefers-reduced-motion: reduce) {
.wave { animation: none; }
}
</style>

View File

@@ -0,0 +1,325 @@
<!--
自视频浮窗Task 15 原创特色 3四角吸附拖拽 + 图钉切回网格
职责
- 桌面端恒为浮窗显示本地视频默认右下角可拖拽到任意位置松手吸附到最近四角
- 提供图钉按钮切换 float grid 模式
- 复用 VideoTile track 挂载能力只在外壳上做拖拽逻辑
设计要点
- H5 生效 H5 直接渲染为空
- mousemove/touchmove 注册在 window 避免 iframe / 外部元素失焦
- onBeforeUnmount 必须移除监听防止泄漏
- 吸附计算记录容器边界矩形释放时分别算到 4 角中心距离取最小
- z-index: 120低于 Toolbar 210低于 MemberPanel 200高于 VideoGrid
-->
<template>
<view
v-if="!hidden"
ref="floatEl"
class="float-shell"
:class="{ dragging: isDragging }"
:style="positionStyle"
@mousedown="onDragStart"
@touchstart="onDragStart"
>
<VideoTile
:user-id="userId"
:name="name"
:is-local="true"
:is-host="isHost"
:audio-enabled="audioEnabled"
:video-enabled="videoEnabled"
:audio-track="audioTrack"
:video-track="videoTrack"
:is-speaking="isSpeaking"
:compact="true"
/>
<view class="pin-btn" title="收回网格" @click.stop="onPinClick" @mousedown.stop @touchstart.stop>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="17" x2="12" y2="22"></line>
<path d="M5 17h14l-1.405-1.405A2 2 0 0 1 17 14.172V11a5 5 0 0 0-10 0v3.172a2 2 0 0 1-.595 1.423L5 17z"></path>
</svg>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
import VideoTile from '@/components/meeting/VideoTile.vue'
const props = defineProps({
userId: { type: [Number, String], required: true },
name: { type: String, default: '你' },
isHost: { type: Boolean, default: false },
audioEnabled: { type: Boolean, default: true },
videoEnabled: { type: Boolean, default: true },
audioTrack: { type: Object, default: null },
videoTrack: { type: Object, default: null },
isSpeaking: { type: Boolean, default: false },
/** 父容器引用,用于计算吸附边界;不传则以 window 为边界 */
container: { type: Object, default: null },
/** 是否隐藏浮窗(例如图钉切换后) */
hidden: { type: Boolean, default: false }
})
const emit = defineEmits(['pin-click'])
const floatEl = ref(null)
// ============ 位置状态 ============
/** 浮窗当前位置(绝对像素 / 容器坐标系);默认右下角 */
const position = ref({ x: 0, y: 0 })
const isDragging = ref(false)
/** 拖拽起始记录 */
const dragStart = ref({ pointerX: 0, pointerY: 0, startX: 0, startY: 0 })
/** 浮窗尺寸:桌面端(宽屏 >= 900px280x180px移动端 / 窄屏 160x100px */
const getDefaultSize = () => {
// #ifdef H5
const isDesktop = typeof window !== 'undefined' && window.innerWidth >= 900
return isDesktop ? { width: 280, height: 180 } : { width: 160, height: 100 }
// #endif
// #ifndef H5
return { width: 160, height: 100 }
// #endif
}
const SIZE = getDefaultSize()
const EDGE_MARGIN = 24
const positionStyle = computed(() => ({
transform: `translate(${position.value.x}px, ${position.value.y}px)`,
width: `${SIZE.width}px`,
height: `${SIZE.height}px`,
transition: isDragging.value ? 'none' : 'transform 0.22s cubic-bezier(0.2, 0.8, 0.2, 1)'
}))
// ============ 计算父容器边界 ============
const getBounds = () => {
// #ifdef H5
const el = resolveDom(props.container) || document.body
const rect = el.getBoundingClientRect()
return {
left: 0,
top: 0,
right: rect.width,
bottom: rect.height
}
// #endif
// #ifndef H5
return { left: 0, top: 0, right: 320, bottom: 240 }
// #endif
}
const resolveDom = (r) => {
// #ifdef H5
if (!r) return null
if (r.$el) return r.$el
if (r instanceof HTMLElement) return r
// #endif
return null
}
/** 初始化位置:右下角 */
const placeDefault = () => {
const b = getBounds()
position.value = {
x: b.right - SIZE.width - EDGE_MARGIN,
y: b.bottom - SIZE.height - EDGE_MARGIN
}
}
/** 松手后吸附到最近四角 */
const snapToCorner = () => {
const b = getBounds()
const cx = position.value.x + SIZE.width / 2
const cy = position.value.y + SIZE.height / 2
const centers = [
// 左上
{ x: EDGE_MARGIN, y: EDGE_MARGIN },
// 右上
{ x: b.right - SIZE.width - EDGE_MARGIN, y: EDGE_MARGIN },
// 左下
{ x: EDGE_MARGIN, y: b.bottom - SIZE.height - EDGE_MARGIN },
// 右下
{ x: b.right - SIZE.width - EDGE_MARGIN, y: b.bottom - SIZE.height - EDGE_MARGIN }
]
let best = centers[3]
let min = Infinity
for (const c of centers) {
const ccx = c.x + SIZE.width / 2
const ccy = c.y + SIZE.height / 2
const d = (cx - ccx) ** 2 + (cy - ccy) ** 2
if (d < min) {
min = d
best = c
}
}
position.value = best
}
// ============ 拖拽交互 ============
const getPoint = (e) => {
// #ifdef H5
if (e.touches && e.touches.length > 0) {
return { x: e.touches[0].clientX, y: e.touches[0].clientY }
}
return { x: e.clientX, y: e.clientY }
// #endif
// #ifndef H5
return { x: 0, y: 0 }
// #endif
}
const onDragStart = (e) => {
// #ifdef H5
if (e.target && e.target.closest && e.target.closest('.pin-btn')) return
const pt = getPoint(e)
dragStart.value = {
pointerX: pt.x,
pointerY: pt.y,
startX: position.value.x,
startY: position.value.y
}
isDragging.value = true
window.addEventListener('mousemove', onDragMove, { passive: false })
window.addEventListener('touchmove', onDragMove, { passive: false })
window.addEventListener('mouseup', onDragEnd)
window.addEventListener('touchend', onDragEnd)
window.addEventListener('touchcancel', onDragEnd)
// 禁用文字选中
document.body.style.userSelect = 'none'
e.preventDefault()
// #endif
}
const onDragMove = (e) => {
// #ifdef H5
if (!isDragging.value) return
const pt = getPoint(e)
const dx = pt.x - dragStart.value.pointerX
const dy = pt.y - dragStart.value.pointerY
const b = getBounds()
// Clamp 在 bounds 内,防止浮窗被拖出视频区域
const nx = Math.max(0, Math.min(dragStart.value.startX + dx, b.right - SIZE.width))
const ny = Math.max(0, Math.min(dragStart.value.startY + dy, b.bottom - SIZE.height))
position.value = { x: nx, y: ny }
if (e.cancelable) e.preventDefault()
// #endif
}
const onDragEnd = () => {
// #ifdef H5
if (!isDragging.value) return
isDragging.value = false
window.removeEventListener('mousemove', onDragMove)
window.removeEventListener('touchmove', onDragMove)
window.removeEventListener('mouseup', onDragEnd)
window.removeEventListener('touchend', onDragEnd)
window.removeEventListener('touchcancel', onDragEnd)
document.body.style.userSelect = ''
snapToCorner()
// #endif
}
// ============ 容器尺寸变化 ============
let _resizeObserver = null
const onContainerResize = () => {
// 重新 clamp + 吸附到当前象限对应的四角
const b = getBounds()
if (position.value.x > b.right - SIZE.width - EDGE_MARGIN ||
position.value.y > b.bottom - SIZE.height - EDGE_MARGIN) {
snapToCorner()
}
}
watch(() => props.container, () => {
// container 引用变化时重新定位
nextTick(placeDefault)
})
const onPinClick = () => {
emit('pin-click')
}
onMounted(() => {
nextTick(placeDefault)
// #ifdef H5
window.addEventListener('resize', onContainerResize)
if (typeof ResizeObserver !== 'undefined' && props.container) {
const el = resolveDom(props.container)
if (el) {
_resizeObserver = new ResizeObserver(onContainerResize)
_resizeObserver.observe(el)
}
}
// #endif
})
onBeforeUnmount(() => {
// #ifdef H5
window.removeEventListener('resize', onContainerResize)
if (_resizeObserver) {
_resizeObserver.disconnect()
_resizeObserver = null
}
// 兜底:组件卸载时仍在拖拽中则强制清理 window 监听
if (isDragging.value) {
onDragEnd()
}
// #endif
})
</script>
<style scoped>
.float-shell {
position: absolute;
top: 0;
left: 0;
z-index: 120;
border-radius: 16rpx;
overflow: hidden;
box-shadow: 0 12rpx 32rpx rgba(0, 0, 0, 0.45);
border: 1rpx solid rgba(255, 255, 255, 0.1);
cursor: grab;
touch-action: none;
user-select: none;
/* transform 直接由 :style 驱动,不再设置 transition由 isDragging 控制) */
}
.float-shell.dragging {
cursor: grabbing;
box-shadow: 0 16rpx 40rpx rgba(0, 0, 0, 0.55);
}
.pin-btn {
position: absolute;
top: 8rpx;
right: 8rpx;
width: 36rpx;
height: 36rpx;
border-radius: 50%;
background: rgba(0, 0, 0, 0.45);
color: rgba(255, 255, 255, 0.9);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background-color 0.15s ease;
z-index: 2;
}
.pin-btn:hover {
background: rgba(0, 0, 0, 0.65);
color: #FFFFFF;
}
@media (prefers-reduced-motion: reduce) {
.float-shell { transition: none; }
}
</style>

View File

@@ -1,9 +1,11 @@
<!--
会议视频网格Task 11
会议视频网格Task 11 / Task 15 柔性网格升级
职责
- 根据成员数量自适应网格布局1/2/3-4/5-6/7-9/10+
- 手机端竖排为主桌面端以 16:9 单元格填充
- 根据成员数量自适应网格布局1/2/3/4-9/10+
- Task 15 原创特色 22 人左右 65/35 非等分3 +两个小三角布局4+ 人维持等分
- 本地 tile 由上层 SelfVideoFloat 承接浮窗当传入 tiles 仅含远端时 layout-* 数字即可对应视觉人数
- 入会滑入动效新成员 tile 挂载时向上位移 + 渐显Task 15 原创特色 5
- 不关心业务数据仅负责排版视频块由 slot #tile 自定义渲染保持数据与组件解耦
-->
<template>
@@ -13,6 +15,7 @@
v-for="(tile, idx) in tiles"
:key="tile.key || tile.userId || idx"
class="grid-cell"
:class="cellClassFor(idx)"
>
<slot name="tile" :tile="tile" :index="idx" />
</view>
@@ -30,28 +33,72 @@ const props = defineProps({
tiles: { type: Array, default: () => [] }
})
/** 根据成员数量计算列数 */
const columns = computed(() => {
/**
* 布局档位:按成员数量决定非等分策略
* - 1: 单屏
* - 2: 左右 65/35
* - 3: 左大 + 右两叠
* - 4-9: 3×3 等分兜底
* - 10+: sqrt 自适应
*/
const layoutClass = computed(() => {
const n = props.tiles.length
if (n <= 1) return 1
if (n <= 4) return 2
if (n <= 9) return 3
if (n <= 16) return 4
return Math.ceil(Math.sqrt(n))
if (n === 0) return 'layout-empty'
if (n === 1) return 'layout-1'
if (n === 2) return 'layout-2-flex'
if (n === 3) return 'layout-3-tri'
if (n <= 9) return 'layout-grid-3'
return 'layout-grid-sqrt'
})
const rows = computed(() => {
/**
* grid-template 计算:
* - layout-2-flex 用 grid-template-columns: 65fr 35fr单行
* - layout-3-tri 用 2 列,左列 1 行、右列 2 行cell 手动控制 span
* - layout-grid-3 同现有逻辑3 列)
* - layout-grid-sqrt 同现有逻辑sqrt 列)
*/
const gridStyle = computed(() => {
const n = props.tiles.length
if (!n) return 1
return Math.ceil(n / columns.value)
if (n === 2) {
return {
'grid-template-columns': '65fr 35fr',
'grid-template-rows': '1fr'
}
}
if (n === 3) {
return {
'grid-template-columns': '60fr 40fr',
'grid-template-rows': '1fr 1fr'
}
}
if (n === 1) {
return {
'grid-template-columns': '1fr',
'grid-template-rows': '1fr'
}
}
if (n <= 9) {
const cols = n <= 4 ? 2 : 3
const rows = Math.ceil(n / cols)
return {
'grid-template-columns': `repeat(${cols}, minmax(0, 1fr))`,
'grid-template-rows': `repeat(${rows}, minmax(0, 1fr))`
}
}
const cols = Math.ceil(Math.sqrt(n))
const rows = Math.ceil(n / cols)
return {
'grid-template-columns': `repeat(${cols}, minmax(0, 1fr))`,
'grid-template-rows': `repeat(${rows}, minmax(0, 1fr))`
}
})
const gridStyle = computed(() => ({
'grid-template-columns': `repeat(${columns.value}, minmax(0, 1fr))`,
'grid-template-rows': `repeat(${rows.value}, minmax(0, 1fr))`
}))
const layoutClass = computed(() => `layout-${Math.min(props.tiles.length, 10)}`)
/** 单元格特殊 class3 人三角布局里,首个 tile 左列跨 2 行 */
const cellClassFor = (idx) => {
if (props.tiles.length === 3 && idx === 0) return 'cell-span-row-2'
return ''
}
</script>
<style scoped>
@@ -76,6 +123,12 @@ const layoutClass = computed(() => `layout-${Math.min(props.tiles.length, 10)}`)
min-width: 0;
min-height: 0;
overflow: hidden;
animation: tile-slide-in 0.28s cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
/* 3 人布局:首个 tile 左列占据整列高度2 行) */
.cell-span-row-2 {
grid-row: span 2;
}
.empty {
@@ -89,12 +142,32 @@ const layoutClass = computed(() => `layout-${Math.min(props.tiles.length, 10)}`)
letter-spacing: 1rpx;
}
/* 移动端单人时占满整屏 */
@media (max-width: 640px) {
.grid.layout-1 { grid-template-rows: 1fr !important; }
.grid.layout-2 {
/* Task 15 原创特色 5入会滑入动效 */
@keyframes tile-slide-in {
from { transform: translateY(16rpx); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
/* 移动端(小屏):柔性布局回退为纵向等分,避免 65/35 在窄屏上过度挤压 */
@media (max-width: 750px) {
.grid.layout-2-flex {
grid-template-columns: 1fr !important;
grid-template-rows: 1fr 1fr !important;
}
.grid.layout-3-tri {
grid-template-columns: 1fr !important;
grid-template-rows: 1fr 1fr 1fr !important;
}
.cell-span-row-2 {
grid-row: span 1;
}
.grid.layout-1 {
grid-template-rows: 1fr !important;
}
}
/* 可访问性:系统偏好减少动效时禁用滑入 */
@media (prefers-reduced-motion: reduce) {
.grid-cell { animation: none; }
}
</style>

View File

@@ -165,10 +165,40 @@ onBeforeUnmount(() => {
transition: box-shadow 0.18s ease, transform 0.18s ease;
border: 2rpx solid transparent;
}
/* Fallback不支持 @property 的浏览器Safari 17-)降级为静态蓝色描边 */
.tile.speaking {
border-color: #3B82F6;
box-shadow: 0 0 0 2rpx rgba(59, 130, 246, 0.18), 0 0 28rpx rgba(59, 130, 246, 0.35);
}
/* EchoChat 原创特色:说话者流光轮廓
通过 @property 声明可动画化的自定义属性 --flow-angle
驱动 conic-gradient 环绕旋转;双层 background 方案:
- padding-box实色填充视频块内容
- border-box渐变仅生效于 border 区域,形成霓虹光圈效果 */
@supports (background: conic-gradient(from 0deg, red, blue)) and (--flow-angle: 0deg) {
@property --flow-angle {
syntax: '<angle>';
initial-value: 0deg;
inherits: false;
}
.tile.speaking {
border-color: transparent;
background:
linear-gradient(#0B1220, #0B1220) padding-box,
conic-gradient(from var(--flow-angle), #10B981, #2563EB, #6366F1, #F59E0B, #10B981) border-box;
box-shadow: 0 0 24rpx rgba(99, 102, 241, 0.45);
animation: flow-rotate 3s linear infinite;
}
}
@keyframes flow-rotate {
to { --flow-angle: 360deg; }
}
/* 可访问性:尊重系统减少动画偏好 */
@media (prefers-reduced-motion: reduce) {
.tile.speaking { animation: none; }
}
.tile.local::after {
content: '';
position: absolute;