vue-3-阿里云视频播放器-如何让用户自己给视频添加节点
目录
vue 3 阿里云视频播放器 如何让用户自己给视频添加节点
vue 3 阿里云视频播放器 如何让用户自己给视频添加节点
带节点截图功能的视频标记
1.自定义视频控制条,在进度条上显示节点标记
2.添加截图功能,保存节点时刻的视频画面
3.右侧面板管理所有节点和截图
<template>
<!-- 成员名单 -->
<el-dialog
v-model="dialogpeopleVisible"
:before-close="handleClose"
class="aliyunplayDialog"
@opened="opena"
>
<!-- :show-close = "false" -->
<!-- <div class="prism-player" id="playerContainer" ></div> -->
<div class="container">
<div class="dialog-content-self">自定义节点标记</div>
<!-- <header>
<h1>Vue 3 阿里云视频播放器 - 自定义节点标记</h1>
<p>当前时间: {{ currentTimeFormatted }}</p>
</header> -->
<div class="main-content">
<div class="video-container">
<div id="player-container"></div>
</div>
<div class="nodes-panel">
<div class="panel-header">
<div>节点管理</div>
<span>总数: {{ nodes.length }}</span>
</div>
<div class="add-node-form">
<div class="form-group">
<label>节点标题</label>
<el-input v-model="newNodeTitle" placeholder="输入节点标题" />
</div>
<div class="form-group">
<div style="display: flex;align-items: center;">
<label>节点图片</label>
<el-button
type="primary"
class="btn-screenshot"
style="margin-left: 12px;"
@click="captureNodeScreenshot($event)"
>
截图
</el-button>
</div>
<div style="display: flex; flex-direction: column">
<el-upload
:class="{ isShowImg: newNodeImg.length > 0 }"
action="#"
list-type="picture-card"
:auto-upload="false"
accept="image/*"
:limit="1"
:on-exceed="handleExceed"
v-model:file-list="newNodeImg"
:on-change="handleChange"
>
<el-icon>
<Plus />
</el-icon>
<template #file="{ file }">
<div>
<img
class="el-upload-list__item-thumbnail"
:src="file.url"
alt=""
/>
<span class="el-upload-list__item-actions">
<span
v-if="!disabled"
class="el-upload-list__item-delete"
@click="handleRemovePicture(file)"
>
<el-icon>
<Delete />
</el-icon>
</span>
</span>
</div>
</template>
</el-upload>
<div style="font-size: 12px; color: #86909c">
{{ $t("view.course.the_image_size_cannot_exceed_2mb!") }}
</div>
</div>
</div>
<div class="form-group">
<label>节点内容</label>
<el-input
:rows="3"
type="textarea"
v-model="newNodeContent"
placeholder="输入节点内容(可选)"
></el-input>
</div>
<div style="display: flex;align-items: center;">
<el-button type="primary" @click="addNode">添加节点</el-button>
<el-button type="primary" @click="completeNode">完成</el-button>
</div>
</div>
<div class="nodes-list" v-if="sortedNodes.length > 0">
<div v-if="sortedNodes.length === 0" class="empty-state">
<p>暂无节点,点击上方按钮添加</p>
</div>
<div
v-for="node in sortedNodes"
:key="node.id"
class="node-item"
@click="jumpToNode(node.time)"
>
<div class="node-header">
<span class="node-time">{{ node.timeFormatted }}</span>
<button class="btn-delete" @click="deleteNode(node.id, $event)">
删除
</button>
</div>
<div>{{ node.title }}</div>
<div class="node-content">{{ node.content }}</div>
</div>
</div>
</div>
</div>
<!-- <div class="instructions">
<h3>使用说明</h3>
<ul>
<li>播放视频到想要添加节点的位置,暂停或继续播放</li>
<li>填写节点标题和内容(可选)</li>
<li>点击"添加节点"按钮,节点将保存在当前时间点</li>
<li>在右侧面板点击任意节点可以跳转到对应时间点</li>
<li>可以删除不需要的节点</li>
</ul>
</div> -->
</div>
</el-dialog>
</template>
<script setup>
import { ref, reactive, onMounted, onUnmounted } from "vue";
import CustomProgressComponent from "@/components/course/CustomProgressComponent/index";
import { ElMessage, ElMessageBox } from "element-plus";
const dialogpeopleVisible = ref(false);
const infoValue = ref({});
const player = ref(null);
const nodes = ref([]);
const newNodeTitle = ref("");
const newNodeContent = ref("");
const newNodeImg = ref([]);
const currentTime = ref(0);
const videoElement = ref(null);
const emit = defineEmits(["close","completeNode"]);
const openDialog = (bool, info) => {
console.log(info, "info");
dialogpeopleVisible.value = bool;
infoValue.value = info;
};
const opena = () => {
initPlayer();
};
// 更新播放器的进度标记
const updatePlayerMarkers = () => {
if (player.value) {
player.value.setProgressMarkers(nodes.value);
}
};
// 初始化播放器
const initPlayer = () => {
if (player.value) {
// 如果已经创建了,就销毁
player.value.dispose();
player.value = null;
}
player.value = new Aliplayer(
{
id: "player-container",
source: infoValue.value.url.replace(/^http:\/\//i, "https://"), // 替换为实际视频URL
width: "100%",
height: "500px",
autoplay: false,
// isLive: false,
// rePlay: false,
// playsinline: true,
// preload: true,
// controlBarVisibility: "hover",
// useH5Prism: true,
skinLayoutIgnore: ["loading"],
//对播放按钮位置修改
skinLayout: [
{ name: "bigPlayButton", align: "cc", x: 30, y: 80 },
{
name: "H5Loading",
align: "cc",
x: 30,
y: 80,
},
{
name: "controlBar",
align: "blabs",
x: 0,
y: 0,
children: [
{
name: "progress",
align: "tlabs",
x: 0,
y: 0,
},
{ name: "playButton", align: "tl", x: 15, y: 12 },
{ name: "timeDisplay", align: "tl", x: 10, y: 6 },
{ name: "fullScreenButton", align: "tr", x: 10, y: 12 },
{ name: "volume", align: "tr", x: 10, y: 10 },
// { name: "setting", align: "tr", x: 10, y: 12 }
],
},
{
name: "fullControlBar",
align: "tlabs",
x: 0,
y: 0,
children: [
{ name: "fullTitle", align: "tl", x: 25, y: 6 },
{ name: "fullNormalScreenButton", align: "tr", x: 24, y: 13 },
{ name: "fullTimeDisplay", align: "tr", x: 10, y: 12 },
{ name: "fullZoom", align: "cc" },
],
},
],
progressMarkers: nodes.value,
components: [
{
name: "CustomProgressComponent",
type: CustomProgressComponent,
},
],
},
() => {
console.log("播放器初始化完成");
// 获取视频DOM元素
setTimeout(() => {
const playerWrap = document.getElementById("player-container");
videoElement.value = playerWrap.querySelector("video");
}, 1000);
}
);
player.value.on("timeupdate", () => {
console.log(player.value.getCurrentTime(), "player.value.getCurrentTime()");
currentTime.value = player.value.getCurrentTime();
});
};
// 添加节点
const addNode = () => {
if (!newNodeTitle.value.trim()) {
ElMessage.error("请输入节点标题");
return;
}
const time = currentTime.value;
const timeFormatted = formatTime(time);
nodes.value.push({
id: Date.now(),
time: time,
timeFormatted: timeFormatted,
title: newNodeTitle.value,
content: newNodeContent.value,
offset: time,
describe: newNodeContent.value,
coverUrl:
newNodeImg.value.length > 0
? URL.createObjectURL(newNodeImg.value[0].raw)
: "",
isCustomized: true,
});
console.log(nodes.value, "nodes.value");
// 更新播放器的进度标记
updatePlayerMarkers();
// 清空表单
newNodeTitle.value = "";
newNodeContent.value = "";
newNodeImg.value = [];
};
//视频节点完成处理
const completeNode = () =>{
emit('completeNode',infoValue.value )
handleClose()
}
// 删除节点
const deleteNode = (id, event) => {
event.stopPropagation();
nodes.value = nodes.value.filter((node) => node.id !== id);
// 更新播放器的进度标记
updatePlayerMarkers();
};
// 跳转到节点时间点
const jumpToNode = (time) => {
if (player.value) {
player.value.seek(time);
}
};
// 格式化时间
const formatTime = (seconds) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins.toString().padStart(2, "0")}:${secs
.toString()
.padStart(2, "0")}`;
};
// 当前时间格式化显示
const currentTimeFormatted = computed(() => {
return formatTime(currentTime.value);
});
// 按时间排序节点
const sortedNodes = computed(() => {
return [...nodes.value].sort((a, b) => a.time - b.time);
});
const handleExceed = (files) => {
ElMessage({
type: "error",
message: t("view.course.only_one_cover_image_can_be_uploaded"),
});
};
const handleChange = (data, fileList) => {
let file = data.raw;
const isJpgPng =
file.type === "image/jpeg" ||
file.type === "image/png" ||
file.type === "image/gif";
if (!isJpgPng) {
ElMessage({
message: t(
"view.course.the_uploaded_file_format_can_only_be_jpg/png/gif"
),
type: "warning",
});
const currIdx = fileList.indexOf(file);
fileList.splice(currIdx, 1);
return false;
}
const isLt2M = file.size / 1024 / 1024 < 10;
if (!isLt2M) {
ElMessage({
message: t("view.course.the_avatar_image_size_cannot_exceed_2mb!"),
type: "warning",
});
const currIdx = fileList.indexOf(file);
fileList.splice(currIdx, 1);
return false;
}
return isJpgPng && isLt2M;
};
const handleRemovePicture = (file) => {
console.log(file,'file');
newNodeImg.value = newNodeImg.value.filter(
(item) => item.name !== file.name
);
}
// base64转file
const base64ToFile = (base64, filename = "") => {
const arr = base64.split(",");
let mime = arr[0].match(/:(.*?);/)[1]; // 匹配出图片类型
mime = mime.replace("data:", ""); // 去掉data:image/png;base64 // 去掉url中的base64,并转化为Uint8Array类型
const bstr = atob(arr[1]);
let n = bstr.length;
const u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
return new File([u8arr], filename, { type: mime });
};
// 为已有节点捕获截图
const captureNodeScreenshot = (event) => {
// event.stopPropagation();
if (!videoElement.value) {
alert("视频元素未准备好,请稍后再试");
return;
}
// 等待视频跳转完成
setTimeout(() => {
// 创建canvas来捕获视频帧
const canvas = document.createElement("canvas");
canvas.width = videoElement.value.videoWidth;
canvas.height = videoElement.value.videoHeight;
const ctx = canvas.getContext("2d");
ctx.drawImage(videoElement.value, 0, 0, canvas.width, canvas.height);
// 将canvas转换为数据URL
const screenshotData = canvas.toDataURL("image/png");
let rawFileAvatar = base64ToFile(screenshotData, "avatar.png")
// console.log(screenshotData,'screenshotData');
let imageUrl = URL.createObjectURL(rawFileAvatar);
newNodeImg.value.push({
name: '节点图片',
url: imageUrl,
raw: rawFileAvatar,
});
}, 300);
};
const handleClose = () => {
dialogpeopleVisible.value = false;
// 清空节点数据,这样 sortedNodes 计算属性会自动更新为空数组
nodes.value = [];
player.value?.dispose(); // 修正变量名
player.value = null;
emit("closeNodes");
};
onUnmounted(() => {
// 清空节点数据,这样 sortedNodes 计算属性会自动更新为空数组
nodes.value = [];
player.value?.dispose();
player.value = null;
emit("closeNodes");
});
defineExpose({ openDialog, handleClose });
</script>
<style lang="scss">
.el-dialog.aliyunplayDialog {
.el-dialog__header {
padding-top: 0px !important;
}
.el-dialog__body {
padding-top: 10px !important;
}
}
.dialog-content-self {
font-size: 20px;
// font-weight: 600;
color: rgb(29, 33, 41);
// margin-bottom: 20px;
}
.prism-player {
width: 750px;
height: 522px;
}
</style>
<style>
.container {
max-width: 1200px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 20px;
/* max-height: 500px; */
}
header {
text-align: center;
padding: 15px 0;
background: linear-gradient(135deg, #6a11cb 0%, #2575fc 100%);
color: white;
border-radius: 10px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.main-content {
display: flex;
gap: 20px;
}
.video-container {
flex: 3;
background: white;
border-radius: 10px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
overflow: hidden;
height: 500px;
}
#player-container {
width: 100%;
height: 400px;
}
.nodes-panel {
flex: 1;
background: white;
border-radius: 10px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
padding: 20px;
display: flex;
flex-direction: column;
overflow-y: auto;
height: 500px;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 1px solid #eee;
}
.add-node-form {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 20px;
padding: 15px;
background: #f9f9f9;
border-radius: 8px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 5px;
}
label {
font-weight: 500;
font-size: 14px;
}
input,
textarea {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
textarea {
min-height: 60px;
resize: vertical;
}
.btn-screenshot{
padding: 10px;
height: 0px;
}
button {
padding: 8px 16px;
background: #4a6cf7;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
transition: background 0.3s;
}
.btn-delete {
background: #f44336;
}
.btn-delete:hover {
background: #e53935;
}
.nodes-list {
flex: 1;
/* overflow-y: auto; */
}
.node-item {
padding: 12px;
border-radius: 6px;
background: #f9f9f9;
margin-bottom: 10px;
cursor: pointer;
transition: background 0.2s;
}
.node-item:hover {
background: #f0f4ff;
}
.node-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 5px;
}
.node-time {
font-weight: 600;
color: #4a6cf7;
}
.node-content {
font-size: 14px;
color: #555;
}
.empty-state {
text-align: center;
padding: 30px;
color: #888;
}
.instructions {
background: white;
border-radius: 10px;
padding: 20px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.instructions h3 {
margin-bottom: 10px;
color: #4a6cf7;
}
.instructions ul {
padding-left: 20px;
}
.instructions li {
margin-bottom: 8px;
font-size: 14px;
}
@media (max-width: 768px) {
.main-content {
flex-direction: column;
}
#player-container {
height: 300px;
}
}
.isShowImg {
.el-upload-list__item.is-ready {
width: 160px;
height: 90px;
border: none;
}
.el-upload-list__item.is-success {
width: 160px;
height: 90px;
border: none;
}
.el-upload--picture-card {
display: none;
}
}
</style>