需要添加直播接口
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user