Files
uniapp-im-shop/uni_modules/tuikit-atomic-x/components/LiveStreamView/LiveStreamView.nvue
2026-01-12 17:52:15 +08:00

506 lines
17 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>