vue 3 阿里云视频播放器 如何让用户自己给视频添加节点
vue 3 阿里云视频播放器 如何让用户自己给视频添加节点
带节点截图功能的视频标记
1.自定义视频控制条,在进度条上显示节点标记
2.添加截图功能,保存节点时刻的视频画面
3.右侧面板管理所有节点和截图
<template><!-- 成员名单 --><el-dialogv-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-buttontype="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><imgclass="el-upload-list__item-thumbnail":src="file.url"alt=""/><span class="el-upload-list__item-actions"><spanv-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><divv-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://"), // 替换为实际视频URLwidth: "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转换为数据URLconst 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>