需要添加直播接口

This commit is contained in:
cbb
2026-01-12 17:52:15 +08:00
parent 83fec2617c
commit 13af9eb303
281 changed files with 313157 additions and 104 deletions

View File

@@ -0,0 +1,466 @@
<template>
<view class="bottom-drawer-container" v-if="modelValue">
<view class="drawer-overlay" @tap="close"></view>
<view class="bottom-drawer" :class="{ 'drawer-open': modelValue }" @click.stop>
<view class="drawer-header">
<view class="user-info-section">
<view class="avatar-container">
<image class="user-avatar" :src="userInfo?.avatar || defaultAvatarURL" mode="aspectFill"></image>
</view>
<view class="user-details">
<text class="username">{{ userInfo?.userName || '' }}</text>
<text class="user-id">ID: {{ userInfo?.userID || '' }}</text>
</view>
</view>
</view>
<view class="drawer-content">
<view class="drawer-actions">
<view class="action-btn" @tap.stop="microphoneOperation">
<view class="action-btn-image-container">
<image class="action-btn-image" v-if="targetMicStatus !== 'OFF'" src="/static/images/mute-mic.png"
mode="aspectFit" />
<image class="action-btn-image" v-else src="/static/images/unmute-mic.png" mode="aspectFit" />
</view>
<text class="action-btn-content" v-if="targetMicStatus !== 'OFF'">{{ micOffText }}</text>
<text class="action-btn-content" v-else>{{ micOnText }}</text>
</view>
<view class="action-btn" @tap="cameraOperation">
<view class="action-btn-image-container">
<image class="action-btn-image" v-if="targetCameraStatus !== 'OFF'" src="/static/images/end-camera.png"
mode="aspectFit" />
<image class="action-btn-image" v-else src="/static/images/start-camera.png" mode="aspectFit" />
</view>
<text class="action-btn-content" v-if="targetCameraStatus !== 'OFF'">{{ cameraOffText }}</text>
<text class="action-btn-content" v-else>{{ cameraOnText }}</text>
</view>
<view class="action-btn" @tap="flipCamera" v-if="showFlip">
<view class="action-btn-image-container">
<image class="action-btn-image" src="/static/images/flip.png" mode="aspectFit" />
</view>
<text class="action-btn-content">翻转</text>
</view>
<view class="action-btn" @tap="handleHangUp" v-if="showHangup">
<view class="action-btn-image-container">
<image class="action-btn-image" src="/static/images/hangup.png" mode="aspectFit" />
</view>
<text class="action-btn-content">{{ hangupText }}</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup>
import {
ref,
computed,
watch
} from 'vue';
import {
useDeviceState
} from "@/uni_modules/tuikit-atomic-x/state/DeviceState";
import {
useCoGuestState
} from "@/uni_modules/tuikit-atomic-x/state/CoGuestState";
import {
useCoHostState
} from "@/uni_modules/tuikit-atomic-x/state/CoHostState";
import {
useLiveSeatState
} from "@/uni_modules/tuikit-atomic-x/state/LiveSeatState";
const defaultAvatarURL = 'https://web.sdk.qcloud.com/component/TUIKit/assets/avatar_01.png';
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
// 点击对象信息:{ userID, userName, seatIndex, avatar, ... }
userInfo: {
type: Object,
default: {}
},
liveID: {
type: String,
default: ''
},
// 展示模式:主播或观众
isAnchorMode: {
type: Boolean,
default: false
},
// 是否当前登录者本人(主播/观众自我操作用)
isSelf: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['update:modelValue']);
const close = () => emit('update:modelValue', false);
// 设备与连麦状态
const {
microphoneStatus,
cameraStatus,
isFrontCamera,
openLocalCamera,
closeLocalCamera,
openLocalMicrophone,
closeLocalMicrophone,
switchCamera,
} = useDeviceState(uni?.$liveID);
const {
exitHostConnection,
coHostStatus
} = useCoHostState(uni?.$liveID);
const {
disconnect
} = useCoGuestState(uni?.$liveID);
const {
seatList,
muteMicrophone,
unmuteMicrophone,
openRemoteCamera,
closeRemoteCamera,
openRemoteMicrophone,
closeRemoteMicrophone,
kickUserOutOfSeat
} = useLiveSeatState(uni?.$liveID);
// 控件显隐与文案
const showMic = computed(() => props.isSelf || (!props.isSelf && !props.isAnchorMode));
const showCamera = computed(() => props.isSelf || (!props.isSelf && !props.isAnchorMode));
const showFlip = computed(() => props.isSelf && targetCameraStatus.value === 'ON');
const showHangup = computed(() => !props.isSelf || (props.isSelf && !props.isAnchorMode));
const micOffText = computed(() => props.isAnchorMode && props.isSelf ? '静音' : '关闭音频');
const micOnText = computed(() => props.isAnchorMode && props.isSelf ? '解除静音' : '打开音频');
const cameraOffText = computed(() => props.isAnchorMode && props.isSelf ? '关闭视频' : '关闭视频');
const cameraOnText = computed(() => props.isAnchorMode && props.isSelf ? '打开视频' : '打开视频');
const hangupText = computed(() => props.isAnchorMode ? '移下麦' : '断开连麦');
const targetSeat = computed(() => {
const list = seatList.value || [];
const userID = props?.userInfo?.userID;
if (!userID || !Array.isArray(list)) return null;
return list.find(item => item?.userInfo?.userID === userID) || null;
});
const targetMicStatus = computed(() => {
return targetSeat.value?.userInfo?.microphoneStatus || 'OFF';
});
const targetCameraStatus = computed(() => {
return targetSeat.value?.userInfo?.cameraStatus || 'OFF';
});
// seatList 变化自动关闭(当目标用户离席)
watch(seatList, (newVal, oldVal) => {
if (!oldVal || !newVal || newVal.length === oldVal.length) return;
if (newVal.length < oldVal.length) {
const missing = oldVal.find(oldItem => !newVal.some(newItem => newItem.userInfo.userID === oldItem.userInfo
.userID));
if (missing && missing.userInfo?.userID === props?.userInfo?.userID) {
close();
}
}
}, {
deep: true,
immediate: true
});
// 操作区
const microphoneOperation = () => {
if (!props.isSelf) {
if (targetMicStatus.value === 'OFF') {
openRemoteMicrophone({
liveID: uni.$liveID,
userID: props.userInfo.userID,
policy: 'UNLOCK_ONLY'
});
uni.showToast({
title: `您已将${props.userInfo.userName || props.userInfo.userID} 解除静音`,
icon: 'none'
})
close();
return;
}
if (targetMicStatus.value === 'ON') {
closeRemoteMicrophone({
liveID: uni.$liveID,
userID: props.userInfo.userID,
});
uni.showToast({
title: `您已将${props.userInfo.userName || props.userInfo.userID} 静音`,
icon: 'none'
})
close();
return;
}
} else {
if (targetMicStatus.value === 'OFF') {
unmuteMicrophone({
liveID: uni.$liveID,
fail: (errorCode) => {
if (errorCode === -2360) {
uni.showToast({
title: '你已被静音',
icon: 'none'
})
}
}
});
close();
return;
}
if (targetMicStatus.value === 'ON') {
muteMicrophone({
liveID: uni.$liveID
});
close();
return;
}
}
};
const cameraOperation = () => {
if (!props.isSelf) {
if (targetCameraStatus.value === 'OFF') {
openRemoteCamera({
liveID: uni.$liveID,
userID: props.userInfo.userID,
policy: 'UNLOCK_ONLY'
});
uni.showToast({
title: `您已将${props.userInfo.userName || props.userInfo.userID} 解除禁画`,
icon: 'none'
})
close();
return;
}
if (targetCameraStatus.value === 'ON') {
closeRemoteCamera({
liveID: uni.$liveID,
userID: props.userInfo.userID,
});
uni.showToast({
title: `您已将${props.userInfo.userName || props.userInfo.userID} 禁画`,
icon: 'none'
})
close();
return;
}
} else {
if (targetCameraStatus.value === 'OFF') {
openLocalCamera({
isFront: isFrontCamera.value,
fail: (errorCode) => {
if (errorCode === -2370) {
uni.showToast({
title: '你已被禁画',
icon: 'none'
})
}
}
});
close();
return;
}
if (targetCameraStatus.value === 'ON') {
closeLocalCamera();
close();
return;
}
}
};
const flipCamera = () => {
if (cameraStatus.value !== 'ON' || !props.isSelf) return;
switchCamera({
isFront: !isFrontCamera.value
});
close();
};
const handleHangUp = () => {
if (props.isAnchorMode) {
// 主播模式:当自己处于主播连线时,提示断开与主播的连线;否则断开与观众的连麦
if (coHostStatus.value === 'CONNECTED') {
uni.showModal({
content: '确定要断开与其他主播的连线吗?',
confirmText: '断开',
success: (res) => {
if (res.confirm) {
exitHostConnection({
liveID: uni?.$liveID
});
}
close();
}
});
} else {
uni.showModal({
content: '确定要断开与其他观众的连麦吗?',
confirmText: '断开',
success: (res) => {
if (res.confirm) {
kickUserOutOfSeat({
liveID: uni?.$liveID,
userID: props.userInfo.userID,
success: () => {},
fail: () => {},
});
}
close();
}
});
}
} else {
// 观众模式:断开当前连麦
uni.showModal({
content: '确定要断开当前连麦吗?',
success: (res) => {
if (res.confirm) {
disconnect({
liveID: uni?.$liveID,
success: () => {},
fail: () => {},
});
close();
}
}
});
}
};
</script>
<style>
.bottom-drawer-container {
position: fixed;
bottom: 0;
left: 0;
right: 0;
top: 0;
z-index: 1000;
}
.drawer-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.4);
}
.bottom-drawer {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: rgba(31, 32, 36, 1);
border-top-left-radius: 32rpx;
border-top-right-radius: 32rpx;
transform: translateY(100%);
transition-property: transform;
transition-duration: 0.3s;
transition-timing-function: ease;
height: 400rpx;
flex-direction: column;
}
.drawer-open {
transform: translateY(0);
}
.drawer-header {
padding: 40rpx 48rpx;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.user-info-section {
flex-direction: row;
align-items: center;
flex: 1;
}
.avatar-container {
width: 80rpx;
height: 80rpx;
margin-right: 24rpx;
}
.user-avatar {
width: 80rpx;
height: 80rpx;
border-radius: 40rpx;
}
.user-details {
flex: 1;
flex-direction: column;
}
.username {
font-size: 32rpx;
color: #C5CCDB;
font-weight: 500;
margin-bottom: 8rpx;
}
.user-id {
font-size: 28rpx;
color: #7C85A6;
}
.drawer-content {
height: 400rpx;
justify-content: flex-start;
padding: 0 48rpx;
}
.drawer-actions {
display: flex;
flex-direction: row;
}
.action-btn {
flex-direction: column;
align-items: center;
height: 160rpx;
margin-left: 10rpx;
width: 120rpx
}
.action-btn-image-container {
width: 100rpx;
height: 100rpx;
background-color: rgba(43, 44, 48, 1);
margin-bottom: 16rpx;
border-radius: 20rpx;
justify-content: center;
align-items: center;
}
.action-btn-image {
width: 50rpx;
height: 50rpx;
}
.action-btn-content {
font-size: 26rpx;
color: #ffffff;
text-align: left;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,506 @@
<template>
<view class="live-stream-view-container">
<view class="live-stream-view-content" :class="{ 'audience-mode': !isAnchor }" @touchend.stop="" :style="{
top: isAnchor ? systemInfo.safeArea.top + 'px' : '0px',
left: isAnchor ? '0px' : systemInfo.safeArea.left + 'px',
width: isAnchor ? safeArea.width + 'px' : systemInfo.safeArea.width + 'px',
height: isAnchor ? systemInfo.safeArea?.height + 'px' : systemInfo.windowHeight + 'px',
}">
<!-- 主播模式:显示默认头像 -->
<view v-if="isAnchor && cameraStatus === 'OFF' && seatList.length === 1 && isLiving" class="default-avatar"
:style="{ top: safeArea.height * 1/2 + 'px' }">
<image style="width: 96rpx; height: 96rpx; border-radius: 99px;"
:src="loginUserInfo.avatarURL || defaultAvatarURL" />
</view>
<!-- 主视频流 -->
<live-core-view class="live-stream-view-background" :liveID="liveID"
:viewType=" isAnchor ? 'PUSH_VIEW' : 'PLAY_VIEW'" @tap="streamViewClick(loginUserInfo)" :style="{
top: '0px',
width: safeArea.width + 'px',
height: streamViewHeight + 'px',
}">
</live-core-view>
<!-- 单人模式(观众视角特有) -->
<view v-if="!isAnchor && seatList?.length === 1" @touchend.stop="" class="grid-content-cell"
v-for="(participant, colIndex) in seatList" :key="`${colIndex}`" @tap="streamViewClick(participant)" :style="{
left: participant?.region?.x * scale.scaleX + 'px',
top: (calculateTopValue(participant) - 2) + 'px',
'background-color': participant?.userInfo?.cameraStatus === 'OFF' ? '#000' : 'transparent',
}">
<view style="display: flex; flex-direction: row; width: 120rpx; justify-content: center; align-items: center;"
:style="{
width: participant?.region?.w * scale.scaleX + 'px',
height: (streamViewHeight + 10) + 'px',
}">
<view v-if="participant?.userInfo?.cameraStatus === 'OFF'" class="video-container">
<image class="participant-video" :src="participant?.userInfo?.avatarURL || defaultAvatarURL"
mode="aspectFill" />
</view>
</view>
</view>
<!-- 多人模式:非网格布局 -->
<view v-if="seatList?.length > 1 && (templateLayout !== 801 && templateLayout !== 800)" @touchend.stop=""
class="grid-content-cell" v-for="(participant, colIndex) in seatList" :key="`${colIndex}`"
@tap="streamViewClick(participant)" style="border-radius: 0rpx;" :style="{
left: participant?.region?.x * scale.scaleX + 'px',
top: calculateTopValue(participant) + (isAnchor ? 0 : -2) + 'px',
'background-color': !isAnchor && participant?.userInfo?.cameraStatus === 'OFF' ? '#000' : 'transparent',
}">
<view style="display: flex; flex-direction: row; width: 120rpx; justify-content: center; align-items: center;"
:style="{
width: participant?.region?.w * scale.scaleX + 'px',
height: isAnchor ? (participant?.region?.h * scale.scaleY + 'px') : (participant?.region?.h * scale.scaleY + 2) + 'px',
}">
<view v-if="participant?.userInfo?.cameraStatus === 'OFF'" class="video-container">
<image class="participant-video" :src="participant?.userInfo?.avatarURL || defaultAvatarURL"
mode="aspectFill" />
</view>
<view class="participant-info-container">
<image v-if="participant?.userInfo?.microphoneStatus === 'OFF'" class="mic-icon"
src="/static/images/unmute-mic.png" mode="aspectFit" />
<text class="participant-name" :style="{ 'max-width': participant?.region.w * scale.scaleX * 0.85 + 'px' }">
{{ participant?.userInfo?.userName || participant?.userInfo?.userID }}
</text>
</view>
</view>
</view>
<!-- 多人模式:网格布局 -->
<view v-if="seatList?.length > 1 && (templateLayout === 801 || templateLayout === 800)" @touchend.stop=""
class="grid-content-cell" v-for="(participant, colIndex) in seatList"
:key="`${isAnchor ? colIndex : participant.index}`" @tap="streamViewClick(participant)"
style="border-radius: 0rpx;" :style="{
left: participant?.region?.x * scale.scaleX + 'px',
top: calculateTopValue(participant) + (isAnchor ? 0 : -2) + 'px',
'background-color': !isAnchor && participant?.userInfo?.cameraStatus === 'OFF' ? '#000' : 'transparent',
}">
<view v-if="participant?.userInfo.userID"
style="display: flex; flex-direction: row; width: 120rpx; justify-content: center; align-items: center;"
:style="{
width: participant?.region?.w * scale.scaleX + 'px',
height: isAnchor ? (participant?.region?.h * scale.scaleY + 'px') : (participant?.region?.h * scale.scaleY + 2) + 'px',
}">
<view v-if="participant?.userInfo?.cameraStatus === 'OFF'" class="video-container">
<image class="participant-video" :src="participant?.userInfo?.avatarURL || defaultAvatarURL"
mode="aspectFill" />
</view>
<view class="participant-info-container" :class="{ 'audience-mode': !isAnchor }">
<image v-if="participant?.userInfo?.microphoneStatus === 'OFF'" class="mic-icon"
src="/static/images/unmute-mic.png" mode="aspectFit" />
<text class="participant-name" :style="{ 'max-width': participant?.region.w * scale.scaleX * 0.85 + 'px' }">
{{ participant?.userInfo?.userName || participant?.userInfo?.userID }}
</text>
</view>
</view>
<view class="participant-wait-container" v-else :style="{
width: participant?.region?.w * scale.scaleX + 'px',
height: participant?.region?.h * scale.scaleY + 'px',
'border-bottom': '1rpx solid #000',
'background-color': '#1f2024',
'border-left': isAnchor && templateLayout === 800 ? '1rpx solid #000' : '',
'border-right': !isAnchor && templateLayout === 800 ? '1rpx solid #000' : '',
}">
<text class="participant-wait-content">{{ isAnchor ? colIndex : participant?.index }}</text>
<text class="participant-wait-content">等待连麦</text>
</view>
</view>
<LiveStreamActionPanel v-if="enableClickPanel" v-model="isShowClickPanel" :liveID="liveID || ''"
:userInfo="clickUserInfo" :isAnchorMode="isAnchor"
:isSelf="(isAnchor ? (clickUserInfo?.userID === loginUserInfo?.userID) : (clickUserInfo?.userID === currentLoginUserId))" />
</view>
</view>
</template>
<script setup lang="ts">
import {
ref,
onMounted,
computed,
watch
} from 'vue';
import {
useLiveSeatState
} from "@/uni_modules/tuikit-atomic-x/state/LiveSeatState";
import {
useLoginState
} from "@/uni_modules/tuikit-atomic-x/state/LoginState";
import {
useCoHostState
} from "@/uni_modules/tuikit-atomic-x/state/CoHostState";
import {
useDeviceState
} from "@/uni_modules/tuikit-atomic-x/state/DeviceState";
import {
useCoGuestState
} from "@/uni_modules/tuikit-atomic-x/state/CoGuestState";
import LiveStreamActionPanel from '@/uni_modules/tuikit-atomic-x/components/LiveStreamView/LiveStreamActionPanel.nvue';
const props = defineProps({
liveID: {
type: String,
default: '',
},
onStreamViewClick: {
type: Function,
default: null
},
isAnchor: {
type: Boolean,
default: true,
},
templateLayout: {
type: Number,
},
isLiving: {
type: Boolean,
default: false,
},
currentLoginUserId: {
type: String,
}
,
enableClickPanel: {
type: Boolean,
default: false,
}
});
const emit = defineEmits(['streamViewClick']);
// 状态管理
const {
loginUserInfo
} = useLoginState();
const {
connected,
coHostStatus
} = useCoHostState(uni?.$liveID);
const {
cameraStatus
} = useDeviceState();
const {
connected: audienceConnected
} = useCoGuestState(uni?.$liveID)
// 使用 rpx
const standardWidth = 750;
let standardHeight = standardWidth * 16 / 9;
const safeArea = ref({
left: 0,
right: 0,
top: 0,
bottom: 0,
width: standardWidth,
height: standardHeight,
});
const systemInfo = ref({});
const deviceWidthRatio = ref(1);
const deviceHeightRatio = ref(1);
const bottomPanelHeight = ref(80);
const pixelWidthRatio = ref(1);
const {
seatList,
canvas,
lockSeat,
} = props?.liveID && useLiveSeatState(props?.liveID);
const platform = ref('android');
const defaultAvatarURL = 'https://web.sdk.qcloud.com/component/TUIKit/assets/avatar_01.png';
const scale = ref({
scaleX: 1,
scaleY: 1
});
const streamViewHeight = ref(1);
const isShowClickPanel = ref(false);
const clickUserInfo = ref<any>({});
function calculateScale(originalWidth : number, originalHeight : number, displayWidth : number, displayHeight : number) {
// 等比缩放(以宽度或高度中较小的比例为准)
const scaleX = displayWidth / originalWidth;
const scaleY = displayHeight / originalHeight;
return { scaleX, scaleY }; // 确保不变形
}
// 初始化加载
onMounted(() => {
uni.getSystemInfo({
success: (res) => {
systemInfo.value = res;
safeArea.value = res.safeArea;
deviceWidthRatio.value = standardWidth / res.windowWidth;
platform.value = res.platform;
if (platform.value === 'ios') {
bottomPanelHeight.value = 60;
}
if (props.isAnchor) {
streamViewHeight.value = systemInfo.value.safeArea.height - bottomPanelHeight.value;
} else {
streamViewHeight.value = systemInfo.value.windowHeight;
}
console.warn(`LiveStreamView: isAnchor=${props.isAnchor}, safeArea: ${JSON.stringify(res.safeArea)},
deviceWidthRatio: ${deviceWidthRatio.value}, deviceHeightRatio: ${deviceHeightRatio.value},
windowWidth: ${res.windowWidth}, windowHeight: ${res.windowHeight},
streamViewHeight: ${streamViewHeight.value}`);
}
});
});
// 下发的视频像素值
watch(canvas, (newValue) => {
if (newValue?.w) {
pixelWidthRatio.value = (safeArea.value.width / newValue?.w) * deviceWidthRatio.value;
console.warn(`LiveStreamView: canvas 变动: ${pixelWidthRatio.value}`)
}
if (newValue?.w && newValue?.h) {
scale.value = calculateScale(
newValue.w,
newValue.h,
systemInfo.value.safeArea.width,
systemInfo.value.safeArea.width * newValue.h / newValue.w,
);
console.warn(
`LiveStreamView: scale value: ${JSON.stringify(scale.value)}, streamViewHeight: ${streamViewHeight.value}`
);
}
});
const streamViewClick = (options) => {
console.log(`LiveStreamView: click options: ${JSON.stringify(options)}, isAnchor: ${props.isAnchor}`);
if (!props.isLiving) return
// 非主播模式且没有参与者信息(背景点击),直接忽略
if (!props.isAnchor && !options?.userInfo) {
return;
}
if (props.isAnchor) {
// 主播模式区分点击背景loginUserInfo和点击参与者带 userInfo
if (options?.userInfo) {
if (audienceConnected.value.length > 1 && connected?.value.length === 0) {
const userInfo = { ...options.userInfo, seatIndex: options.index };
if (!userInfo?.userID) return;
if (props.enableClickPanel) {
clickUserInfo.value = userInfo;
isShowClickPanel.value = true;
}
}
if (connected?.value.length > 0) {
if (options?.userInfo.userID === uni.$userID) {
const userInfo = { ...options.userInfo, seatIndex: options.index };
if (!userInfo?.userID) return;
if (props.enableClickPanel) {
clickUserInfo.value = userInfo;
isShowClickPanel.value = true;
}
}
}
} else {
// 背景点击:直接把当前登录用户信息抛给上层,保持原有行为
// props.onStreamViewClick?.(options);
// emit('streamViewClick', options);
if (props.enableClickPanel) {
clickUserInfo.value = { ...(loginUserInfo.value || {}), userID: loginUserInfo.value?.userID } as any;
isShowClickPanel.value = true;
}
}
} else {
// 面板限制规则仅影响面板,不影响事件下发给页面
const isClickSelf = options?.userInfo?.userID === props?.currentLoginUserId;
const isTemplateGrid = props.templateLayout === 800 || props.templateLayout === 801;
const isCoGuesting = uni.$localGuestStatus !== 'DISCONNECTED';
const payload = { ...options.userInfo, seatIndex: options.index };
if (props.enableClickPanel && isClickSelf) {
clickUserInfo.value = payload as any;
isShowClickPanel.value = true;
return
}
props.onStreamViewClick?.(payload);
emit('streamViewClick', payload);
}
};
// 返回 px 对应的计算值
const calculateTopValue = (participant) => {
let topValue = 1;
console.log(
`LiveStreamView: templateLayout: ${props.templateLayout}, platform: ${platform.value}, isAnchor: ${props.isAnchor}`
);
if (!participant) return topValue;
if (props.templateLayout !== 800 && props.templateLayout !== 801) {
topValue = participant.region.y * scale.value.scaleY;
}
if (props.templateLayout === 800) {
topValue = participant.region.y * scale.value.scaleY;
}
if (props.templateLayout === 801) {
topValue = participant.region.y * scale.value.scaleY;
}
console.log(`LiveStreamView: topValue: ${topValue}`);
return topValue;
};
watch(seatList, (newValue) => {
if ((newValue || []).length > 1 && canvas.value?.w && canvas.value?.h) {
if (props.isAnchor) {
streamViewHeight.value = systemInfo.value.safeArea.height - bottomPanelHeight.value;
} else {
streamViewHeight.value = systemInfo.value.windowHeight;
}
}
if (!props.isAnchor && (newValue || []).length === 1 && canvas.value?.w && canvas.value?.h &&
audienceConnected.value.length === 1) {
streamViewHeight.value = systemInfo.value.windowHeight;
}
console.log(`LiveStreamView: seatList change streamViewHeight: ${streamViewHeight.value}`);
});
// 观众模式特有的状态监听
if (!props.isAnchor) {
watch(audienceConnected.value, (newVal, oldVal) => {
console.log(
`LiveStreamView: audience localStatus change, newVal: ${newVal}, oldVal: ${oldVal}, templateLayout: ${props.templateLayout}`
);
if (newVal.length === 1) {
streamViewHeight.value = systemInfo.value.windowHeight;
console.log(`LiveStreamView: audience localStatus change streamViewHeight: ${streamViewHeight.value}`);
}
});
}
defineExpose({
streamViewClick,
calculateTopValue,
scale,
streamViewHeight
});
</script>
<style>
.live-stream-view-container {
flex: 1;
position: relative;
background: rgba(15, 16, 20, 0.5);
z-index: -1;
overflow: hidden;
}
.live-stream-view-content {
flex: 1;
position: relative;
}
.live-stream-view-content.audience-mode {
background-color: #000;
}
.live-stream-view-background {
position: relative;
right: 0;
bottom: 0;
z-index: -1;
}
.grid-image-cell {
position: absolute;
border-radius: 24rpx;
overflow: hidden;
z-index: 1000;
}
.participant-video {
width: 80rpx;
height: 80rpx;
border-radius: 80rpx;
}
.grid-content-cell {
flex: 1;
position: absolute;
border-radius: 24rpx;
overflow: hidden;
}
.participant-info-container {
position: absolute;
display: flex;
flex-direction: row;
align-items: center;
background: rgba(34, 38, 46, 0.4);
border-radius: 38rpx;
height: 36rpx;
padding-left: 6rpx;
padding-right: 12rpx;
left: 6rpx;
bottom: 6rpx;
flex: 1;
}
.participant-info-container.audience-mode {
/* 观众模式下的特殊样式 */
}
.mic-icon {
width: 24rpx;
height: 24rpx;
margin-left: 4rpx;
background-color: rgba(34, 38, 46, 0);
}
.participant-name {
font-size: 24rpx;
font-weight: 500;
color: #ffffff;
margin-left: 2rpx;
text-align: center;
min-width: 80rpx;
lines: 1;
text-overflow: ellipsis;
overflow: hidden;
}
.participant-wait-container {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.participant-wait-content {
font-size: 28rpx;
font-weight: 500;
text-align: center;
color: #ffffff;
margin-top: 12rpx;
}
.default-avatar {
position: sticky;
left: 0;
right: 0;
display: flex;
justify-content: center;
align-items: center;
}
</style>

View File

@@ -0,0 +1,47 @@
<template>
<view class="bottom-drawer-container" v-if="modelValue">
<view class="drawer-overlay" @tap="close"></view>
<view class="bottom-drawer" :class="{ 'drawer-open': modelValue }">
<view class="avatar-container">
<image class="avatar" :src="userInfo?.avatarURL || defaultAvatarURL" mode="aspectFill" />
</view>
<view class="user-info-container" v-if="isShowAnchor">
<text class="user-name">{{ userInfo?.userName || userInfo?.userID || '' }}</text>
<text class="user-roomid">直播房间ID{{ userInfo?.liveID || userInfo?.roomId }}</text>
</view>
<view class="user-info-container" v-if="!isShowAnchor">
<text class="user-name">{{ userInfo?.userName || userInfo?.userID || '' }}</text>
<text class="user-roomid">UserId{{ userInfo?.userID }}</text>
</view>
</view>
</view>
</template>
<script setup>
const defaultAvatarURL = 'https://web.sdk.qcloud.com/component/TUIKit/assets/avatar_01.png';
const props = defineProps({
modelValue: { type: Boolean, default: false },
userInfo: { type: Object, default: () => ({}) },
isShowAnchor: { type: Boolean, default: true }
});
const emit = defineEmits(['update:modelValue']);
const close = () => { emit('update:modelValue', false); };
</script>
<style>
.bottom-drawer-container { position: fixed; bottom: 0; left: 0; right: 0; top: 0; z-index: 1000; }
.drawer-overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(15, 16, 20, 0.8); }
.bottom-drawer { position: absolute; bottom: 0; left: 0; right: 0; background: rgba(34, 38, 46, 1); transition-property: transform; transition-duration: 0.3s; transition-timing-function: ease; flex-direction: column; align-items: center; height: 400rpx; padding: 20rpx 0; border-top-left-radius: 32rpx; border-top-right-radius: 32rpx; overflow: hidden; }
.drawer-open { transform: translateY(0); }
.avatar-container { width: 200rpx; height: 120rpx; justify-content: center; align-items: center; position: absolute; }
.avatar { width: 112rpx; height: 112rpx; border-radius: 56rpx; }
.user-info-container { flex: 1; padding-top: 120rpx; align-items: center; }
.user-name { font-size: 32rpx; color: rgba(255, 255, 255, 0.9); }
.user-roomid { font-size: 24rpx; color: rgba(255, 255, 255, 0.55); margin: 20rpx 0; }
.divider-line-container { height: 68rpx; justify-content: center; position: relative; align-items: center; }
.divider-line { width: 268rpx; height: 10rpx; border-radius: 200rpx; background-color: rgba(255, 255, 255, 1); position: absolute; bottom: 16rpx; }
</style>