feat: 集成音频播放组件和视频播放器

This commit is contained in:
2026-02-11 14:49:16 +08:00
parent 9a92e6ffb4
commit c6feeeef8b
48 changed files with 15614 additions and 1584 deletions

View File

@@ -5,38 +5,13 @@
<!-- 页面内容 -->
<view class="page-content">
<!-- 视频播放器 -->
<view class="video-section">
<VideoPlayer
v-if="videoList.length > 0"
ref="videoPlayerRef"
v-model:current-index="currentVideoIndex"
:video-list="videoList"
:countdown-seconds="5"
/>
</view>
<!-- 课程和章节信息 -->
<view class="info-section">
<view class="info-item">
<text class="label">{{ $t('courseDetails.courseInfo') }}</text>
<text class="value">{{ courseTitle }}</text>
</view>
<view class="info-item">
<text class="label">{{ $t('courseDetails.chapterInfo') }}</text>
<text class="value">{{ chapterTitle }}</text>
</view>
</view>
<!-- 视频列表 -->
<view v-if="videoList.length > 0" class="video-list-section">
<view class="section-title">{{ $t('courseDetails.videoTeaching') }}</view>
<wd-radio-group v-model="currentVideoIndex" shape="button" >
<wd-radio v-for="(video, index) in videoList" :key="video.id" :value="index" class="mb-2!">
{{ video.type == 2 ? $t('courseDetails.audio') : $t('courseDetails.video') }}{{ index + 1 }}
</wd-radio>
</wd-radio-group>
</view>
<!-- 课程视频 -->
<AliPlayer
:video-list="videoList"
:current-index="currentVideoIndex !== null ? currentVideoIndex : 0"
:course="{courseTitle:courseTitle, chapterTitle: chapterTitle}"
:cover="curriculumImgUrl || ''"
/>
<!-- 选项卡 -->
<wd-tabs v-model="currentTab" class="tabs-section" lineWidth="30">
@@ -74,7 +49,7 @@
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { courseApi } from '@/api/modules/course'
import VideoPlayer from '@/components/video-player/index.vue'
import AliPlayer from '@/components/ali-video/index.vue'
import type { IChapterDetail } from '@/types/course'
import type { IVideoInfo } from '@/types/video'
@@ -82,6 +57,7 @@ import type { IVideoInfo } from '@/types/video'
const chapterId = ref<number>(0)
const courseTitle = ref('')
const chapterTitle = ref('')
const curriculumImgUrl = ref('')
// 页面数据
const chapterDetail = ref<IChapterDetail | null>(null)
@@ -100,6 +76,7 @@ onLoad((options: any) => {
chapterId.value = parseInt(options.id)
courseTitle.value = options.courseTitle || ''
chapterTitle.value = options.title || ''
curriculumImgUrl.value = options.curriculumImgUrl || ''
loadChapterDetail()
})

View File

@@ -236,7 +236,7 @@ const handleToDetail = (chapter: IChapter, catalogue: ICatalogue) => {
const noRecored = chapter.isAudition === 1 && catalogue.isBuy === 0 && !userVip.value
uni.navigateTo({
url: `/pages/course/details/chapter?id=${chapter.id}&courseId=${courseId.value}&courseTitle=${courseDetail.value?.title}&title=${chapter.title}&noRecored=${noRecored}`
url: `/pages/course/details/chapter?id=${chapter.id}&courseId=${courseId.value}&courseTitle=${courseDetail.value?.title}&title=${chapter.title}&noRecored=${noRecored}&curriculumImgUrl=${courseDetail.value?.image}`
})
}

View File

@@ -1,139 +1,190 @@
<template>
<div class="menu-container">
<!-- 一级导航 -->
<div class="menu-level-1">
<div
v-for="item in level1"
:key="item.id"
class="menu-item"
:class="{ active: selectedParentId === item.id }"
@click="selectParent(item)"
>
{{ item.name }}
</div>
</div>
<!-- 二级导航区域 -->
<transition name="fade">
<div
v-if="childList.length"
class="menu-level-2"
:style="{ gridRowStart: childRowIndex }"
>
<div
v-for="child in childList"
:key="child.id"
class="menu-item child"
>
{{ child.name }}
</div>
</div>
</transition>
</div>
<view>
<yb-video ref="videoRef" title="测试视频" height="auto"
:crossOrigin="crossOrigin"
:src="src"
:three="three"
:danmu="danmu"
:quality="quality"
:subtitles="subtitles"
:works="works"
:workIndex="workIndex"
:custom="custom"
header
controls
@back="back"
@workchange="handleWorkChange"
@qualitychange="handleQualityChange"
@loadedmetadata="handleLoaded">
<view style="position: absolute;top:50px;left:50px;color:#fff" @tap="handleClickSlot">这是组件插槽仅在renderjs渲染类型下有效</view>
</yb-video>
<input class="danmu-input" v-model="text" type="text" placeholder="输入弹幕" />
<button @tap="sendDanmu(1)">发送滚动弹幕</button>
<button @tap="sendDanmu(5)">发送顶端弹幕</button>
<button @tap="sendDanmu(4)">发送底端弹幕</button>
<button @tap="toggle">播放/暂停</button>
<button @tap="changeSrc(0)">切换3D</button>
<button @tap="changeSrc(2)">切换2D</button>
<button @tap="back">返回VUE2</button>
</view>
</template>
<script setup>
import { ref, computed } from "vue"
// 模拟接口返回的一级导航
const level1 = ref([
{ id: 1, name: "导航1", children: [] },
{ id: 2, name: "导航2", children: [
{ id: 21, name: "子2-1" },
{ id: 22, name: "子2-2" },
{ id: 23, name: "子2-3" }
]},
{ id: 3, name: "导航3", children: [] },
{ id: 4, name: "导航4", children: [
{ id: 41, name: "子4-1" },
{ id: 42, name: "子4-2" }
]},
{ id: 5, name: "导航5", children: [] },
{ id: 6, name: "导航6", children: [
{ id: 61, name: "子6-1" },
{ id: 62, name: "子6-2" },
{ id: 63, name: "子6-3" },
{ id: 64, name: "子6-4" },
]},
{ id: 7, name: "导航7", children: [] },
{ id: 8, name: "导航8", children: [] }
])
// 选择的一级导航
const selectedParentId = ref(null)
// 当前一级导航的子级
const childList = computed(() => {
const parent = level1.value.find(i => i.id === selectedParentId.value)
return parent ? parent.children : []
})
// 计算二级导航应该显示在哪一行(每行 4 个)
const childRowIndex = computed(() => {
if (!selectedParentId.value) return 3
const index = level1.value.findIndex(i => i.id === selectedParentId.value)
return Math.floor(index / 4) + 2 // 第一行 row=1二级导航从 row=2 或 row=3
})
const selectParent = (item) => {
// 点击同一个时关闭
if (selectedParentId.value === item.id) {
selectedParentId.value = null
} else {
selectedParentId.value = item.id
}
}
import { ref, nextTick } from 'vue';
import { onReady } from '@dcloudio/uni-app'
const videoRef = ref('')
const src = ref('')
const three = ref('')
const crossOrigin = ref('')
const danmu = ref([])
const quality = ref([])
const subtitles = ref([{
title: 'ASS字幕',
src: '/static/subtitle/[zmk.pw]我的世界大电影精译v3.ass'
},{
title: 'SRT字幕',
src: '/static/subtitle/wednesday.1080p.web.h264-successfulcrab-hi.srt'
},{
title: 'VTT字幕',
src: '/static/subtitle/sing-song_2025-09-05_103755.vtt'
}])
const works = ref([])
const workIndex = ref(0)
const custom = ref({
header: {
more: [{
text: '测试按钮',
click: () => {
videoRef.value.showToast('点击了测试按钮')
}
}]
},
progress: {
leftSlots: [{
innerHTML: `
<div class="yb-player-icon">测试按钮</div>
`,
click: () => {
videoRef.value.showToast('点击了测试按钮')
}
}],
rightSlots: [{
innerHTML: `
<div class="custom_danmu">
<svg t="1756368791455" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4446" width="48" height="48"><path d="M628.01 470.03h88.851v71.575h-88.85V470.03zM628.01 361.426h88.851v69.107h-88.85v-69.107zM497.184 470.03h86.382v71.575h-86.382V470.03z" p-id="4447"></path><path d="M798.06 65.29H225.939c-81.839 0-148.204 68.834-148.204 153.746V795.52c0 84.93 67.835 163.189 151.559 163.189h565.393c83.724 0 151.577-78.277 151.577-163.189V219.036c0.001-84.911-66.344-153.746-148.202-153.746zM541.61 213.325c11.497 21.402 23.032 49.363 34.567 83.922-14.815 4.938-31.287 11.535-49.383 19.744-6.596-29.62-16.453-56.771-29.611-81.454l44.427-22.212zM400.927 586.04l-9.876 116.01c-3.318 32.91-17.718 52.644-43.201 59.26a631.2 631.2 0 0 1-77.75 14.796c-3.317-21.412-9.065-41.966-17.284-61.71 37.847 1.658 62.52 1.235 74.056-1.234 11.479-2.47 18.076-13.572 19.735-33.323l9.876-123.42h-118.48c6.558-52.651 9.875-109.413 9.875-170.321H358.97v-91.32c-54.32 0-97.125 0.848-128.357 2.469v-44.435c26.294 1.668 51.832 2.47 76.505 2.47h98.747a1257.144 1257.144 0 0 0-2.488 78.983c0 26.341 0.83 56.78 2.488 91.34H292.323l-4.938 88.86h120.93c-3.318 23.05-5.767 46.884-7.388 71.575z m397.398 101.205c-32.947-1.611-60.9-2.469-83.932-2.469h-86.382c0 37.877 0.79 74.045 2.469 108.614h-49.363c1.62-37.884 2.45-74.055 2.45-108.614H487.31c-26.33 1.67-49.363 3.318-69.116 4.948v-46.903c19.753 1.648 42.785 3.307 69.116 4.928h96.258v-66.648H447.821c1.62-41.108 2.469-85.543 2.469-133.286 0-47.715-0.848-88.86-2.47-123.418h180.19c14.816-34.558 27.952-69.918 39.506-106.145 18.058 8.256 37 15.664 56.753 22.223-16.474 18.123-32.08 46.084-46.895 83.922h86.382c-1.658 31.278-2.469 73.234-2.469 125.878 0 52.681 0.81 96.268 2.47 130.827H628.01v66.648h83.913c21.374 0 50.174-0.81 86.4-2.47v41.965z" p-id="4448"></path><path d="M497.184 361.426h86.382v69.107h-86.382v-69.107z" p-id="4449"></path></svg>
</div>
`,
click: () => {
videoRef.value.showToolbar({
selector: '.custom_danmu',
list: [{
text: '自定义工具1',
click: () => {
videoRef.value.showToast('自定义工具1')
}
},{
text: '自定义工具2',
click: () => {
videoRef.value.showToast('自定义工具2')
}
},{
text: '自定义工具3',
click: () => {
videoRef.value.showToast('自定义工具3')
}
}]
})
}
}]
}
})
const text = ref('')
onReady(() => {
changeSrc(2)
})
const changeSrc = (index) => {
const arr = [
'/static/video/test-360.mp4',//如果网页开启了/h5/的前缀,就需要加上这个/h5/
'https://v2-zj-scct.kwaicdn.com/upic/2025/08/14/20/BMjAyNTA4MTQyMDEwMDVfMzc5NDk1NDg1Nl8xNzIzNjYyNjIwNDdfMF8z_b_B92c62e9609ee404af820675b37447d6d.mp4?tag=1-1756166590-unknown-0-wo6mf8iejp-e4f1ab14369cda6a&provider=self&clientCacheKey=3x4mikyhc8uu4jk_b.mp4&di=b6859a4d&bp=14944&x-ks-ptid=172366262047&kwai-not-alloc=self-cdn&kcdntag=p:Sichuan;i:ChinaTelecom;ft:UNKNOWN;h:COLD;pn:kuaishouVideoProjection&Aecs=172.19.0.226&ocid=100000971&tt=b&ss=vps',
'https://ydtnt-jw.oss-cn-zhangjiakou.aliyuncs.com/jw-video/%E8%A7%86%E9%A2%91/%E8%8B%B1%E8%AF%AD/%E5%94%90%E8%BF%9F%E8%AF%8D%E6%B1%87%E7%9A%84%E9%80%BB%E8%BE%91%E5%8D%95%E8%AF%8D%E8%AF%BE/Unit1-1_batch.mp4'
]
quality.value = []
for ( let i = 0 ; i < 3; i++ ) {
quality.value.push({
title: i == 0 ? '480p' : i == 1 ? '720p' : '1080p',
src: videoRef.value.parseSrc(arr[index]),
type: 'video'
})
}
works.value = []
for ( let i = 0 ; i < 10; i++ ) {
works.value.push({
title: '第' + (i + 1) + '集',
src: arr[index]
})
}
workIndex.value = 0
three.value = index == 0 ? '360' : 'none'
crossOrigin.value = index == 0 ? 'anonymous' : ''
src.value = arr[index]
}
const handleWorkChange = (e) => {
src.value = e.detail.src
videoRef.value.showToast('由于测试切换链接和当前播放链接是同一个所以不会触发视频切换')
}
const handleQualityChange = (e) => {
this.$refs.video.showToast('由于所有画质链接都是一样的,所以怎么切换都会索引到第一个画质')
}
const handleLoaded = (e) => {
if ( e.type == 'init' ) {
danmu.value = []
for (var i = 0 ; i < 8000; i++) {
danmu.value.push({
mode: 1,
time: 1 * i / 2, // 弹幕出现的时间(单位:毫秒)
text: '这是新增的一条弹幕' + i, // 弹幕文本内容
color: '#0ff', // 该条弹幕的颜色,会覆盖全局设置
})
}
nextTick(() => {
videoRef.value.loadDanmu()
})
}
}
const handleClickSlot = () => {
videoRef.value.showToast('点击了组件插槽')
}
const toggle = () => {
videoRef.value.toggle()
}
const sendDanmu = (mode) => {
if ( !text.value ) return
videoRef.value.sendDanmu({
mode,
text: text.value,
color: '#ffffff',
fontSize: 18
}, true)//true表示使用边框
videoRef.value.showToast({message: '发送弹幕成功', duration: 5000})
}
const back = () => {
uni.navigateBack()
}
</script>
<style scoped>
.menu-container {
display: grid;
grid-template-rows: auto auto auto;
gap: 10px;
}
/* 一级导航4列布局 */
.menu-level-1 {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
}
.menu-item {
padding: 12px;
text-align: center;
background: #4a90e2;
color: white;
border-radius: 6px;
cursor: pointer;
}
.menu-item.active {
background: #2d73c7;
}
/* 二级导航:自动换行 */
.menu-level-2 {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
background: #2d73c7;
padding: 10px;
border-radius: 6px;
}
.menu-item.child {
background: #1e4f8a;
}
/* 动画 */
.fade-enter-active, .fade-leave-active {
transition: all .2s ease;
}
.fade-enter-from, .fade-leave-to {
opacity: 0;
transform: translateY(-5px);
}
</style>
<style>
.danmu-input {
border: 1px solid #eee;
height: 45px;
padding: 0 10px;
}
</style>

View File

@@ -75,6 +75,9 @@
<text v-if="item.hufenState" class="menu-list-hufen">{{hufenData?.total ?? 0}}<text
style="margin-left: 6rpx;">湖分</text></text>
</wd-cell>
<wd-cell is-link @click="uni.navigateTo({
url: '/pages/index'
})">前往测试</wd-cell>
</wd-cell-group>
</view>
</view>