feat(live): 优化直播间功能并添加画中画支持

- 更新直播源地址
- 添加画中画功能,支持视频拖动和缩放
- 实现直播加密密钥生成和数据解密
- 优化直播房间组件,支持全屏和缩略图模式
- 新增日志发送接口
This commit is contained in:
xingyy 2025-02-11 11:34:24 +08:00
parent 10cba595b0
commit 86198811aa
9 changed files with 445 additions and 57 deletions

View File

@ -46,3 +46,11 @@ export async function artworkBuy(data) {
data
})
}
export async function logSendlog(data) {
return await request( {
url:'/api/v1/m/auction/log/sendlog',
method: 'POST',
data
})
}

View File

@ -0,0 +1,207 @@
<template>
<div
v-show="isVisible"
:class="['floating-video', { minimized: isMinimized }]"
class="!aspect-video"
:style="[positionStyle, containerStyle]"
@touchstart.prevent="handleTouchStart"
@touchmove.prevent="handleTouchMove"
@touchend="handleTouchEnd"
>
<video
ref="videoRef"
class="video-player "
controls
@loadedmetadata="handleVideoMetadata"
>
<source src="@/static/video/example.mp4" type="video/mp4" />
您的浏览器不支持 HTML5 视频
</video>
<div class="control-bar">
<div class="minimize-btn" @click="toggleMinimize">
<span v-if="isMinimized"></span>
<span v-else></span>
</div>
<div class="close-btn" @click="closeVideo"></div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
const props = defineProps({
isVisible: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['close'])
const videoRef = ref(null)
const aspectRatio = ref(16 / 9) //
const isMinimized = ref(false)
const position = ref({ x: 20, y: 20 })
const isDragging = ref(false)
const dragStart = ref({ x: 0, y: 0 })
// 0
const windowSize = ref({
width: 0,
height: 0
})
const handleVideoMetadata = () => {
const video = videoRef.value
if (video.videoWidth && video.videoHeight) {
aspectRatio.value = video.videoWidth / video.videoHeight
}
}
const containerDimensions = computed(() => {
const baseWidth = isMinimized.value ? 150 : 300
const height = baseWidth / aspectRatio.value
return { width: baseWidth, height }
})
const containerStyle = computed(() => {
return {
width: `${containerDimensions.value.width}px`,
height: `${containerDimensions.value.height}px`
}
})
const positionStyle = computed(() => ({
transform: `translate3d(${position.value.x}px, ${position.value.y}px, 0)`
}))
const handleTouchStart = (event) => {
isDragging.value = true
const touch = event.touches[0]
dragStart.value = {
x: touch.clientX - position.value.x,
y: touch.clientY - position.value.y
}
}
const handleTouchMove = (event) => {
if (!isDragging.value) return
const touch = event.touches[0]
let newX = touch.clientX - dragStart.value.x
let newY = touch.clientY - dragStart.value.y
//
const maxX = windowSize.value.width - containerDimensions.value.width
const maxY = windowSize.value.height - containerDimensions.value.height
newX = Math.max(0, Math.min(newX, maxX))
newY = Math.max(0, Math.min(newY, maxY))
requestAnimationFrame(() => {
position.value = { x: newX, y: newY }
})
}
const handleTouchEnd = () => {
isDragging.value = false
}
const toggleMinimize = () => {
isMinimized.value = !isMinimized.value
const maxX = windowSize.value.width - containerDimensions.value.width
const maxY = windowSize.value.height - containerDimensions.value.height
position.value = {
x: Math.max(0, Math.min(position.value.x, maxX)),
y: Math.max(0, Math.min(position.value.y, maxY))
}
}
const closeVideo = () => {
emit('close')
}
const handleResize = () => {
windowSize.value = {
width: window.innerWidth,
height: window.innerHeight
}
const maxX = windowSize.value.width - containerDimensions.value.width
const maxY = windowSize.value.height - containerDimensions.value.height
position.value = {
x: Math.max(0, Math.min(position.value.x, maxX)),
y: Math.max(0, Math.min(position.value.y, maxY))
}
}
//
onMounted(() => {
//
windowSize.value = {
width: window.innerWidth,
height: window.innerHeight
}
window.addEventListener('resize', handleResize)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize)
})
</script>
<style scoped>
/* 样式部分保持不变 */
.floating-video {
position: fixed;
z-index: 1000;
background: #000;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
transition: width 0.3s ease, height 0.3s ease;
overflow: hidden;
transform: translate3d(0, 0, 0);
will-change: transform;
touch-action: none;
}
.video-player {
width: 100%;
height: 100%;
object-fit: fill!important;
}
.control-bar {
position: absolute;
top: 0;
right: 0;
display: flex;
gap: 8px;
padding: 8px;
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.5), transparent);
}
.minimize-btn,
.close-btn {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
cursor: pointer;
color: white;
font-size: 14px;
}
.minimize-btn:hover,
.close-btn:hover {
background: rgba(255, 255, 255, 0.3);
}
</style>

View File

@ -4,16 +4,21 @@ import {goodStore} from "@/stores/goods/index.js";
import ItemList from './components/ItemList/index.vue'
import Cescribe from './components/Cescribe/index.vue'
import {message} from '@/components/x-message/useMessage.js'
import floatingVideo from '@/components/floatingVideo/index.vue'
import {liveStore} from "~/stores/live/index.js";
const {fullLive,getAuctionDetail,auctionDetail} = goodStore();
const {getLiveLink}= liveStore()
const changeLive = () => {
fullLive.value = true;
};
if (!auctionDetail.value.uuid){
await getAuctionDetail()
}
</script>
<template>
<div class="flex-grow-1">
<!-- <floatingVideo :is-visible="true"></floatingVideo>-->
<client-only>
<liveRoom @click="changeLive" :class="['changeLive', fullLive ? 'expanded' : 'collapsed']"
:fullLive="fullLive"/>
@ -29,6 +34,7 @@ if (!auctionDetail.value.uuid){
</van-tabs>
<van-back-top right="15vw" bottom="10vh"/>
</div>
</div>
</template>
<style scoped lang="scss">

View File

@ -1,50 +1,94 @@
<script setup>
import {ref, onMounted, onBeforeUnmount} from 'vue'
import {ref, onMounted, onBeforeUnmount, computed, watch} from 'vue'
import Aliplayer from 'aliyun-aliplayer'
import 'aliyun-aliplayer/build/skins/default/aliplayer-min.css'
import sideButton from '@/pages/liveRoom/components/SideButton/index.vue'
import broadcast from '@/pages/liveRoom/components/Broadcast/index.vue'
import {liveStore} from "@/stores/live/index.js";
import {liveStore} from "@/stores/live/index.js"
import paymentResults from '@/pages/liveRoom/components/PaymentResults/index.vue'
import paymentInput from '@/pages/liveRoom/components/PaymentInput/index.vue'
import xButton from '@/components/x-button/index.vue'
import {goodStore} from "@/stores/goods/index.js";
import {message} from "~/components/x-message/useMessage.js";
import {artworkBuy} from "@/api/goods/index.js";
const {auctionDetail,getAuctionDetail} = goodStore();
import {goodStore} from "@/stores/goods/index.js"
import {message} from "~/components/x-message/useMessage.js"
import {artworkBuy} from "@/api/goods/index.js"
import CryptoJS from 'crypto-js'
const {auctionDetail, getAuctionDetail} = goodStore()
const player = ref(null)
const {quoteStatus, changeStatus, show, playerId, show1,auctionData,getSocketData} = liveStore()
const {quoteStatus, changeStatus, show, playerId, show1, auctionData, getSocketData,getLiveLink} = liveStore()
const isPlayerReady = ref(false)
const pullLink=ref('')
const props = defineProps({
fullLive: {
type: Boolean,
default: true,
},
})
const isPiPActive = ref(false)
const videoRef = ref(null)
//
const isPiPSupported = computed(() => {
return document.pictureInPictureEnabled ||
document.webkitPictureInPictureEnabled
})
//
const enterPiP = async () => {
try {
if (!videoRef.value) return
if (document.pictureInPictureElement) {
await document.exitPictureInPicture()
}
await videoRef.value.requestPictureInPicture()
isPiPActive.value = true
} catch (error) {
console.error('进入画中画模式失败:', error)
}
}
// 退
const exitPiP = async () => {
try {
if (document.pictureInPictureElement) {
await document.exitPictureInPicture()
isPiPActive.value = false
}
} catch (error) {
console.error('退出画中画模式失败:', error)
}
}
definePageMeta({
title: '主页',
i18n: 'login.title',
})
const config = useRuntimeConfig()
const playerConfig = {
id: playerId.value,
source: config.public.NUXT_PUBLIC_PLAYER_SOURCE,
isLive: true,
preload: true,
autoplayPolicy: {fallbackToMute: true},
controlBarVisibility: 'never',
}
const handlePlayerError = (error) => {
console.error('播放器错误:', error)
if (player.value) {
player.value?.play()
}
}
const initializePlayer = () => {
try {
if (player.value) {
player.value?.dispose()
}
const playerConfig = {
id: playerId.value,
source:pullLink.value,
isLive: true,
preload: true,
autoplayPolicy: {fallbackToMute: true},
controlBarVisibility: 'never',
}
player.value = new Aliplayer(playerConfig, (playerInstance) => {
isPlayerReady.value = true
playerInstance?.play()
@ -60,19 +104,23 @@ const initializePlayer = () => {
}
onMounted(async () => {
/* initializePlayer()*/
pullLink.value= await getLiveLink()
initializePlayer()
})
onBeforeUnmount(() => {
if (player.value) {
player.value?.dispose()
player.value = null
}
})
const goPay = () => {
show.value = true
}
const fullLive1 = ref(false)
watch(()=>{
watch(() => {
return props.fullLive
}, (newVal) => {
if (newVal) {
@ -80,59 +128,98 @@ watch(()=>{
setTimeout(() => {
fullLive1.value = true
}, 400)
}else {
} else {
fullLive1.value = false
}
})
const goBuy=async ()=>{
const res= await artworkBuy({
auctionArtworkUuid:auctionData.value?.artwork?.uuid,
buyMoney:String(auctionData.value?.nowAuctionPrice?.nextPrice??0)
const goBuy = async () => {
const res = await artworkBuy({
auctionArtworkUuid: auctionData.value?.artwork?.uuid,
buyMoney: String(auctionData.value?.nowAuctionPrice?.nextPrice ?? 0)
})
if (res.status===0){
if (res.status === 0) {
message.success('出价成功')
}
}
const tipOpen=()=>{
const tipOpen = () => {
message.warning('出价状态未开启')
}
//
const cryptConfig = {
password: 'live-skkoql-1239-key',
salt: 'aldk100128ls',
iterations: 10000,
keySize: 32
}
//
const generateKey = (password, salt, iterations, keySize) => {
return CryptoJS.PBKDF2(password, salt, {
keySize: keySize / 4,
iterations: iterations,
hasher: CryptoJS.algo.SHA1
}).toString(CryptoJS.enc.Hex)
}
//
const decrypt = (ciphertextBase64, key) => {
const combined = CryptoJS.enc.Base64.parse(ciphertextBase64)
const iv = CryptoJS.lib.WordArray.create(combined.words.slice(0, 4))
const ciphertext = CryptoJS.lib.WordArray.create(combined.words.slice(4))
const decrypted = CryptoJS.AES.decrypt(
{ciphertext: ciphertext},
CryptoJS.enc.Hex.parse(key),
{
iv: iv,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7
}
)
return decrypted.toString(CryptoJS.enc.Utf8)
}
// 使
const decryptData = (encryptedData) => {
const keyDerived = generateKey(
cryptConfig.password,
cryptConfig.salt,
cryptConfig.iterations,
cryptConfig.keySize
)
return decrypt(encryptedData, keyDerived)
}
</script>
<template>
<div class="relative h-full ">
<div class="w-full h-full">
<video
class="h-full w-full"
autoplay
loop
muted
playsinline
style=" object-fit: cover"
>
<source src="@/static/video/example.mp4" type="video/mp4" />
您的浏览器不支持 HTML5 视频
</video>
</div>
<!-- <div :id="playerId" class="w-screen"
:style="fullLive?'height: calc(100vh - var(&#45;&#45;van-nav-bar-height))':'height:100%'"></div>-->
<div :id="playerId" class="w-full h-full"></div>
<transition>
<div v-if="fullLive1">
<sideButton class="absolute top-196px right-0 z-999"></sideButton>
<div class="absolute left-1/2 transform -translate-x-1/2 flex flex-col items-center" style="bottom:calc(var(--safe-area-inset-bottom) + 26px)">
<div class="absolute left-1/2 transform -translate-x-1/2 flex flex-col items-center"
style="bottom:calc(var(--safe-area-inset-bottom) + 26px)">
<div class="text-16px text-#FFB25F font-600">
当前价{{auctionData?.nowAuctionPrice?.currency}}
<van-rolling-text class="my-rolling-text" :start-num="0" :duration="0.5" :target-num="auctionData?.nowAuctionPrice?.nowPrice??0" direction="up"/>
当前价{{ auctionData?.nowAuctionPrice?.currency }}
<van-rolling-text class="my-rolling-text" :start-num="0" :duration="0.5"
:target-num="auctionData?.nowAuctionPrice?.nowPrice??0" direction="up"/>
</div>
<div class="text-16px text-#fff font-600">
下口价{{auctionData?.nowAuctionPrice?.currency}}
<van-rolling-text class="my-rolling-text1" :start-num="0" :duration="0.5" :target-num="auctionData?.nowAuctionPrice?.nextPrice??0" direction="up"/>
下口价{{ auctionData?.nowAuctionPrice?.currency }}
<van-rolling-text class="my-rolling-text1" :start-num="0" :duration="0.5"
:target-num="auctionData?.nowAuctionPrice?.nextPrice??0" direction="up"/>
</div>
<div v-if="quoteStatus" class="mt-10px mb-10px">
<van-button @click="goBuy" color="#FFB25F" class="w-344px !h-[40px]">
<div>{{`确认出价 ${auctionData?.nowAuctionPrice?.currency} ${auctionData?.nowAuctionPrice?.nextPrice??0}`}}</div>
<div>{{
`确认出价 ${auctionData?.nowAuctionPrice?.currency} ${auctionData?.nowAuctionPrice?.nextPrice ?? 0}`
}}
</div>
</van-button>
</div>
<div v-else class="mt-10px mb-10px">
@ -147,19 +234,26 @@ const tipOpen=()=>{
<div>
</div>
<paymentResults v-model:show="show1" type="error"/>
<div v-if="auctionData?.wsType==='newArtwork'" class="w-344px h-31px rounded-4px absolute top-9px bg-[#151824]/45 backdrop-blur-[10px] backdrop-saturate-[180%] left-1/2 transform translate-x--1/2 flex text-#fff text-14px items-center px-12px line-height-none">
<div class="mr-11px whitespace-nowrap">LOT{{auctionData.artwork.index}}</div>
<div class="mr-10px truncate">{{auctionData.artwork.name}}</div>
<div v-if="auctionData?.wsType==='newArtwork'"
class="w-344px h-31px rounded-4px absolute top-9px bg-[#151824]/45 backdrop-blur-[10px] backdrop-saturate-[180%] left-1/2 transform translate-x--1/2 flex text-#fff text-14px items-center px-12px line-height-none">
<div class="mr-11px whitespace-nowrap">LOT{{ auctionData.artwork.index }}</div>
<div class="mr-10px truncate">{{ auctionData.artwork.name }}</div>
<div class="whitespace-nowrap">开始拍卖</div>
</div>
</div>
</transition>
</div>
</template>
<style lang="scss">
#J_prismPlayer{
width: 100%;
height: 100%!important;
&>video{
width: 100%;
height: 100%;
}
}
</style>
<style scoped>
.v-enter-active,
.v-leave-active {
@ -170,6 +264,7 @@ const tipOpen=()=>{
.v-leave-to {
opacity: 0;
}
.my-rolling-text {
--van-rolling-text-item-width: 10px;
--van-rolling-text-font-size: 16px;

View File

View File

@ -4,9 +4,11 @@ import {goodStore} from "@/stores/goods/index.js";
import {authStore} from "@/stores/auth/index.js";
import {message} from "~/components/x-message/useMessage.js";
import { WebSocketClient } from '@/utils/websocket'
import {logSendlog} from "~/api/goods/index.js";
import CryptoJS from "crypto-js";
export const liveStore = createGlobalState(() => {
const {auctionDetail,getAuctionDetail} = goodStore();
const {auctionDetail} = goodStore();
const { token } = authStore()
const quoteStatus = ref(false)
const show = ref(false)
@ -16,6 +18,70 @@ export const liveStore = createGlobalState(() => {
const auctionData=ref({})
const socket=ref(null)
const config = useRuntimeConfig()
const pullLink=ref('')
// 解密工具函数
const decryptUtils = {
// 解密配置
cryptConfig: {
password: 'live-skkoql-1239-key',
salt: 'aldk100128ls',
iterations: 10000,
keySize: 32
},
// 生成密钥
generateKey(password, salt, iterations, keySize) {
return CryptoJS.PBKDF2(password, salt, {
keySize: keySize / 4,
iterations: iterations,
hasher: CryptoJS.algo.SHA1
}).toString(CryptoJS.enc.Hex)
},
// AES解密
decrypt(ciphertextBase64, key) {
const combined = CryptoJS.enc.Base64.parse(ciphertextBase64)
const iv = CryptoJS.lib.WordArray.create(combined.words.slice(0, 4))
const ciphertext = CryptoJS.lib.WordArray.create(combined.words.slice(4))
const decrypted = CryptoJS.AES.decrypt(
{ciphertext: ciphertext},
CryptoJS.enc.Hex.parse(key),
{
iv: iv,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7
}
)
return decrypted.toString(CryptoJS.enc.Utf8)
},
// 解密数据的主函数
decryptData(encryptedData) {
const keyDerived = this.generateKey(
this.cryptConfig.password,
this.cryptConfig.salt,
this.cryptConfig.iterations,
this.cryptConfig.keySize
)
return this.decrypt(encryptedData, keyDerived)
}
}
const getLiveLink = () => {
return new Promise(async(resolve, reject) => {
const res = await logSendlog({
uuid: auctionDetail.value.uuid
})
if (res.status===0){
pullLink.value=decryptUtils.decryptData(res.data.code)
resolve(decryptUtils.decryptData(res.data.code))
}
})
}
const getSocketData = async () => {
const wsClient = new WebSocketClient(
config.public.NUXT_PUBLIC_SOCKET_URL,
@ -163,6 +229,8 @@ export const liveStore = createGlobalState(() => {
}
return{
pullLink,
getLiveLink,
auctionData,
getSocketData,
show1,

2
env/.env.test vendored
View File

@ -4,4 +4,4 @@ NUXT_PUBLIC_API_COLLECT_CODE=https://auction-test.szjixun.cn
NUXT_API_SECRET=test-secret
NUXT_PUBLIC_SOCKET_URL=ws://172.16.100.99:8005
# 阿里云播放器配置
NUXT_PUBLIC_PLAYER_SOURCE=artc://live-pull-sh-01.szjixun.cn/live/live?auth_key=1737080180-0-0-42ad4cf26ba26eee78ca7de9c524d1e0
NUXT_PUBLIC_PLAYER_SOURCE=artc://live-push-sh-01.szjixun.cn/live001/86180cae-1e07-4b8d-b45e-50d8ce800110?auth_key=1739255918-0-0-5251017e725a860570a59de7e4e2fd98

View File

@ -23,6 +23,7 @@
"@yeger/vue-masonry-wall": "^5.0.17",
"aliyun-aliplayer": "^2.28.5",
"axios": "^1.7.9",
"crypto-js": "^4.2.0",
"dayjs": "^1.11.13",
"dotenv": "^16.4.7",
"nuxt": "^3.15.0",

View File

@ -29,6 +29,9 @@ importers:
axios:
specifier: ^1.7.9
version: 1.7.9
crypto-js:
specifier: ^4.2.0
version: 4.2.0
dayjs:
specifier: ^1.11.13
version: 1.11.13